Skip to content

Commit b17d7fa

Browse files
Merge pull request #578 from components-web-app/fix/574-trim-identifier
Trim the identifier in user repository lookups (#574)
2 parents 6f07272 + efbc05d commit b17d7fa

9 files changed

Lines changed: 187 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Every change that reaches `main` adds a line under **Unreleased**. When a versio
1010
### Fixed
1111
- A user referenced by IRI from another resource's write is no longer processed as a new registration, which reset their `emailAddressVerified` ([#575](https://github.com/components-web-app/api-components-bundle/pull/575))
1212
- A `plainPassword` set through the API is validated (6 to 4096 characters, 422 on `plainPassword`) before it is hashed, and setting a password no longer marks the email address verified; only a password reset does ([#577](https://github.com/components-web-app/api-components-bundle/pull/577))
13+
- User lookups trim the identifier, so a username or email address with a trailing space (a mobile keyboard's) still signs in and still finds the user for password reset and resend verification ([#578](https://github.com/components-web-app/api-components-bundle/pull/578))
1314

1415
## [2.0.0-alpha.39](https://github.com/components-web-app/api-components-bundle/compare/2.0.0-alpha.38...2.0.0-alpha.39) - 2026-10-30
1516

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ Resources are fetched and cached **individually**. Never embed related data outs
304304
- **A failed send returns false whether or not a logger is wired** (#340): the logger is optional in `UserMailer`'s service locator, so check `has('logger')`; `get()` on a missing id throws.
305305
- **The `…RequestedAt` timestamp starts the throttle, so `UserMailer` sets it only after `send()` succeeds**, and flushes it with the token then. A failed send (503) saves nothing: the action refreshes the user, so the token is not replaced by one nobody received.
306306
- Actions calling `UserDataProcessor::findUserByUsername()` must catch its `InvalidArgumentException` and return 404; it throws rather than returning null.
307-
- **Code typed against `UserRepositoryInterface` may call only what it declares** (#266, #336). The `@method` tags are not a contract, and PHPStan trusts them, so it will not catch a call to `find()` or `findOneBy()`. `loadUserByIdentifier()` matches username *or* email, so check the returned username and treat `NonUniqueResultException` as not found; `Repository\User\FindUserByUsernameTrait` does both. Test against `tests/Command/InMemoryUserRepository.php`, which implements only the interface.
307+
- **Code typed against `UserRepositoryInterface` may call only what it declares** (#266, #336). The `@method` tags are not a contract, and PHPStan trusts them, so it will not catch a call to `find()` or `findOneBy()`. `loadUserByIdentifier()` matches username *or* email, trimmed and lower-cased like every `UserRepository` lookup (#574: a mobile keyboard's trailing space), so check the returned username, compared the same way and treat `NonUniqueResultException` as not found; `Repository\User\FindUserByUsernameTrait` does both. Test against `tests/Command/InMemoryUserRepository.php`, which implements only the interface.
308308
- `user:create` throws `ValidationFailedException` rather than writing an invalid or duplicate user (#254). There is no DB unique constraint on username or email.
309309
- Security routes: one path each. A duplicate path fails nothing and one route silently never runs; check with `debug:router`.
310310
- `GET /_/health` (#312) is a plain Symfony route (not an AP operation, which would add JSON-LD, cache headers and tags), `no-store`, 200 or 503 with the DBAL message logged at warning and never returned. It is imported by `routing/all.php`.

‎src/Repository/User/FindUserByUsernameTrait.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@ private function findUserByUsernameIn(UserRepositoryInterface $repository, strin
2424
return null;
2525
}
2626

27-
return null !== $user && strtolower((string) $user->getUsername()) === strtolower($username) ? $user : null;
27+
return null !== $user && strtolower((string) $user->getUsername()) === strtolower(trim($username)) ? $user : null;
2828
}
2929
}

‎src/Repository/User/UserRepository.php‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public function findOneByEmail(string $value): ?AbstractUser
4343
{
4444
return $this->createQueryBuilder('u')
4545
->andWhere('LOWER(u.emailAddress) = :val')
46-
->setParameter('val', strtolower($value))
46+
->setParameter('val', strtolower(trim($value)))
4747
->getQuery()
4848
->getOneOrNullResult();
4949
}
@@ -57,7 +57,7 @@ public function findOneWithPasswordResetToken(string $username): ?AbstractUser
5757
->andWhere('LOWER(u.username) = :username')
5858
->andWhere('u.newPasswordConfirmationToken IS NOT NULL')
5959
->andWhere('u.passwordRequestedAt > :minimumDateTime')
60-
->setParameter('username', strtolower($username))
60+
->setParameter('username', strtolower(trim($username)))
6161
->setParameter('minimumDateTime', $minimumRequestDateTime)
6262
->getQuery()
6363
->getOneOrNullResult();
@@ -73,8 +73,8 @@ public function findOneByUsernameAndNewEmailAddress(string $username, string $em
7373
->andWhere('LOWER(u.newEmailAddress) = :email')
7474
->andWhere('u.newEmailConfirmationToken IS NOT NULL')
7575
->andWhere('u.newEmailAddressChangeRequestedAt > :minimumDateTime')
76-
->setParameter('username', strtolower($username))
77-
->setParameter('email', strtolower($email))
76+
->setParameter('username', strtolower(trim($username)))
77+
->setParameter('email', strtolower(trim($email)))
7878
->setParameter('minimumDateTime', $minimumRequestDateTime)
7979
->getQuery()
8080
->getOneOrNullResult();
@@ -90,7 +90,7 @@ public function loadUserByIdentifier(string $identifier): ?AbstractUser
9090
return $this->createQueryBuilder('u')
9191
->andWhere('LOWER(u.username) = :username')
9292
->orWhere('LOWER(u.emailAddress) = :username')
93-
->setParameter('username', strtolower($identifier))
93+
->setParameter('username', strtolower(trim($identifier)))
9494
->getQuery()
9595
->getOneOrNullResult();
9696
}

‎tests/Functional/User/ForgotPasswordTest.php‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ public function test_i_can_request_a_new_password(): void
3232
self::assertEmailOfTypeSent('password_reset', 'test.user@example.com');
3333
}
3434

35+
public function test_a_username_with_a_trailing_space_still_requests_a_new_password(): void
36+
{
37+
$client = static::createClient();
38+
self::createUserWithRole('my_username', self::generatedSecret(), 'ROLE_USER');
39+
40+
self::requestJsonLd($client, 'GET', '/password/reset/request/my_username%20', null, self::referer());
41+
42+
self::assertResponseStatusCodeSame(200);
43+
self::assertEmailOfTypeSent('password_reset', 'test.user@example.com');
44+
}
45+
3546
public function test_i_get_the_appropriate_status_code_if_the_user_is_not_found(): void
3647
{
3748
$client = static::createClient();

‎tests/Functional/User/SecurityTest.php‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,29 @@ public function test_a_successful_login(): void
6363
self::assertMercureCookieAndTokenExpireIn(604800);
6464
}
6565

66+
#[DataProvider('identifiersWithSurroundingSpace')]
67+
public function test_a_login_identifier_with_surrounding_space_still_signs_in(string $identifier): void
68+
{
69+
$client = static::createClient();
70+
$password = self::generatedSecret();
71+
self::createUserWithRole('user', $password, 'ROLE_USER', 'user@example.com');
72+
73+
self::login($client, $identifier, $password);
74+
75+
self::assertResponseStatusCodeSame(204);
76+
self::assertRefreshTokenCount(1);
77+
}
78+
79+
/**
80+
* @return iterable<string, array{string}>
81+
*/
82+
public static function identifiersWithSurroundingSpace(): iterable
83+
{
84+
yield 'a username with a trailing space' => ['user '];
85+
yield 'a username with a leading space and capitals' => [' USER'];
86+
yield 'an email address with a trailing space' => ['user@example.com '];
87+
}
88+
6689
public function test_a_logged_in_user_can_fetch_a_list_of_all_users(): void
6790
{
6891
$client = static::createClient();

‎tests/Functional/User/VerifyEmailAddressTest.php‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,18 @@ public function test_i_can_request_a_new_email_address_verification_email(): voi
7373
self::assertEmailOfTypeSent('verify_email');
7474
}
7575

76+
public function test_a_username_with_a_trailing_space_still_requests_a_new_verification_email(): void
77+
{
78+
$client = static::createClient();
79+
$user = self::createUserWithRole('my_username', self::generatedSecret(), 'ROLE_USER', 'user@example.com');
80+
self::unverifyEmail($user);
81+
82+
self::requestJsonLd($client, 'GET', '/resend-verify-email/my_username%20', null, self::REFERER);
83+
84+
self::assertResponseStatusCodeSame(200);
85+
self::assertEmailOfTypeSent('verify_email');
86+
}
87+
7688
public function test_requesting_a_verification_email_for_an_unknown_username_is_a_404_not_an_error(): void
7789
{
7890
$client = static::createClient();
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Silverback\ApiComponentsBundle\Tests\Repository\User;
13+
14+
use Doctrine\ORM\NonUniqueResultException;
15+
use PHPUnit\Framework\Attributes\DataProvider;
16+
use PHPUnit\Framework\TestCase;
17+
use Silverback\ApiComponentsBundle\Entity\User\AbstractUser;
18+
use Silverback\ApiComponentsBundle\Repository\User\FindUserByUsernameTrait;
19+
use Silverback\ApiComponentsBundle\Repository\User\UserRepositoryInterface;
20+
use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\User;
21+
22+
final class FindUserByUsernameTraitTest extends TestCase
23+
{
24+
/**
25+
* @return iterable<string, array{string, string, bool}>
26+
*/
27+
public static function usernames(): iterable
28+
{
29+
yield 'the username' => ['my_username', 'my_username', true];
30+
yield 'the username in capitals with surrounding space' => ['my_username', ' MY_USERNAME ', true];
31+
yield 'a username stored in capitals' => ['My_Username', 'my_username', true];
32+
yield 'the email address the identifier also matches' => ['my_username', 'user@example.com', false];
33+
}
34+
35+
#[DataProvider('usernames')]
36+
public function test_only_the_users_own_username_finds_them(string $storedUsername, string $username, bool $found): void
37+
{
38+
$user = new User($storedUsername, 'user@example.com');
39+
$repository = $this->createStub(UserRepositoryInterface::class);
40+
$repository->method('loadUserByIdentifier')->willReturn($user);
41+
42+
self::assertSame($found ? $user : null, $this->finder()->find($repository, $username));
43+
}
44+
45+
public function test_a_username_matching_several_users_finds_none(): void
46+
{
47+
$repository = $this->createStub(UserRepositoryInterface::class);
48+
$repository->method('loadUserByIdentifier')->willThrowException(new NonUniqueResultException());
49+
50+
self::assertNull($this->finder()->find($repository, 'my_username'));
51+
}
52+
53+
private function finder(): object
54+
{
55+
return new class {
56+
use FindUserByUsernameTrait;
57+
58+
public function find(UserRepositoryInterface $repository, string $username): ?AbstractUser
59+
{
60+
return $this->findUserByUsernameIn($repository, $username);
61+
}
62+
};
63+
}
64+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Silverback\ApiComponentsBundle\Tests\Repository\User;
13+
14+
use PHPUnit\Framework\Attributes\DataProvider;
15+
use Silverback\ApiComponentsBundle\Repository\User\UserRepository;
16+
use Silverback\ApiComponentsBundle\Tests\Functional\TestBundle\Entity\User;
17+
use Silverback\ApiComponentsBundle\Tests\Helper\OrphanedResource\OrphanedResourceDatabaseTestCase;
18+
19+
final class UserRepositoryTest extends OrphanedResourceDatabaseTestCase
20+
{
21+
/**
22+
* @return iterable<string, array{\Closure(UserRepository): ?User}>
23+
*/
24+
public static function lookupsWithSurroundingSpace(): iterable
25+
{
26+
yield 'a username identifier' => [static fn (UserRepository $repository) => $repository->loadUserByIdentifier(" My_Username \t")];
27+
yield 'an email address identifier' => [static fn (UserRepository $repository) => $repository->loadUserByIdentifier(' User@Example.com ')];
28+
yield 'a username through the deprecated loader' => [static fn (UserRepository $repository) => $repository->loadUserByUsername('MY_username ')];
29+
yield 'an email address' => [static fn (UserRepository $repository) => $repository->findOneByEmail(' User@Example.COM ')];
30+
yield 'a username with a password reset token' => [static fn (UserRepository $repository) => $repository->findOneWithPasswordResetToken(' My_Username ')];
31+
yield 'a username and new email address' => [static fn (UserRepository $repository) => $repository->findOneByUsernameAndNewEmailAddress(' My_Username ', ' New@Example.com ')];
32+
}
33+
34+
#[DataProvider('lookupsWithSurroundingSpace')]
35+
public function test_a_lookup_ignores_surrounding_whitespace_and_case(\Closure $lookup): void
36+
{
37+
$user = $this->persistUser();
38+
39+
self::assertSame($user, $lookup($this->repository()));
40+
}
41+
42+
public function test_an_identifier_that_matches_no_one_finds_no_user(): void
43+
{
44+
$this->persistUser();
45+
46+
self::assertNull($this->repository()->loadUserByIdentifier('my user'));
47+
}
48+
49+
private function persistUser(): User
50+
{
51+
$user = new User('my_username', 'user@example.com');
52+
$user
53+
->setNewPasswordConfirmationToken('token')
54+
->setPasswordRequestedAt(new \DateTime())
55+
->setNewEmailAddress('new@example.com')
56+
->setNewEmailConfirmationToken('token')
57+
->setNewEmailAddressChangeRequestedAt(new \DateTime());
58+
$this->timestampedDataPersister->persistTimestampedFields($user, true);
59+
$this->entityManager->persist($user);
60+
$this->entityManager->flush();
61+
62+
return $user;
63+
}
64+
65+
private function repository(): UserRepository
66+
{
67+
return new UserRepository($this->registry, User::class, 3600, 3600);
68+
}
69+
}

0 commit comments

Comments
 (0)