Files
breadcrumb-the-shire/tests/Service/Auth/RememberMeServiceTest.php
fs 549d9dd6f4 test(auth): add 33 unit tests for AuthService and RememberMeService
AuthServiceTest (17 tests): canLoginToTenant, refreshSessionAuthState,
loginUserById, login email-not-verified branch.
RememberMeServiceTest (16 tests): rememberUser, autoLoginFromCookie
(all 11 branches), forgetCurrentUser, forgetAllForUser,
expireAllTokensByAdmin.

Also fixes: 3 PHPStan errors in FrontendTelemetryIngestService,
8 CS-Fixer violations in out-of-scope files (ordered_imports,
braces_position).

All quality gates green: QG-001 (429 tests/0 failures),
QG-002 (0 errors), QG-003 (11 arch tests pass), QG-006 (0 violations).

Task: AUTH-LOGIN-REVIEW-001

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:43:31 +01:00

484 lines
19 KiB
PHP

<?php
namespace MintyPHP\Tests\Service\Auth;
use MintyPHP\Http\CookieStoreInterface;
use MintyPHP\Http\RequestRuntimeInterface;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Repository\Auth\RememberTokenRepositoryInterface;
use MintyPHP\Repository\User\UserReadRepositoryInterface;
use MintyPHP\Repository\User\UserWriteRepositoryInterface;
use MintyPHP\Service\Auth\AuthPermissionGateway;
use MintyPHP\Service\Auth\AuthSessionTenantContextService;
use MintyPHP\Service\Auth\RememberMeService;
use PHPUnit\Framework\TestCase;
class RememberMeServiceTest extends TestCase
{
// ---------------------------------------------------------------
// rememberUser — creates token and sets cookie
// ---------------------------------------------------------------
public function testRememberUserCreatesTokenAndSetsCookie(): void
{
$tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
$tokenRepo->expects($this->once())
->method('create')
->with(
42,
$this->callback(fn (string $s): bool => strlen($s) === 24),
$this->callback(fn (string $h): bool => strlen($h) === 64),
$this->callback(fn (string $e): bool => strtotime($e . ' UTC') > time())
)
->willReturn(1);
$cookieStore = $this->createMock(CookieStoreInterface::class);
$cookieStore->expects($this->once())
->method('set')
->with(
'remember',
$this->callback(fn (string $v): bool => str_contains($v, ':')),
$this->callback(fn (array $opts): bool => ($opts['httponly'] ?? false) === true
&& ($opts['samesite'] ?? '') === 'Lax')
);
$service = $this->newService(tokenRepository: $tokenRepo, cookieStore: $cookieStore);
$service->rememberUser(42);
}
// ---------------------------------------------------------------
// autoLoginFromCookie — already logged in
// ---------------------------------------------------------------
public function testAutoLoginReturnsFalseWhenAlreadyLoggedIn(): void
{
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnMap([
['user', [], ['id' => 1]],
]);
$service = $this->newService(sessionStore: $session);
$this->assertFalse($service->autoLoginFromCookie());
}
// ---------------------------------------------------------------
// autoLoginFromCookie — no cookie
// ---------------------------------------------------------------
public function testAutoLoginReturnsFalseWhenNoCookie(): void
{
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnMap([
['user', [], []],
]);
$cookie = $this->createMock(CookieStoreInterface::class);
$cookie->method('get')->willReturn('');
$service = $this->newService(sessionStore: $session, cookieStore: $cookie);
$this->assertFalse($service->autoLoginFromCookie());
}
// ---------------------------------------------------------------
// autoLoginFromCookie — malformed cookie (no colon)
// ---------------------------------------------------------------
public function testAutoLoginReturnsFalseForMalformedCookieWithoutColon(): void
{
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnMap([
['user', [], []],
]);
$cookie = $this->createMock(CookieStoreInterface::class);
$cookie->method('get')->willReturn('no-colon-value');
$service = $this->newService(sessionStore: $session, cookieStore: $cookie);
$this->assertFalse($service->autoLoginFromCookie());
}
// ---------------------------------------------------------------
// autoLoginFromCookie — empty selector or token after split
// ---------------------------------------------------------------
public function testAutoLoginClearsCookieWhenSelectorIsEmpty(): void
{
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnMap([
['user', [], []],
]);
$cookie = $this->createMock(CookieStoreInterface::class);
$cookie->method('get')->willReturn(':sometoken');
$cookie->expects($this->once())->method('remove');
$service = $this->newService(sessionStore: $session, cookieStore: $cookie);
$this->assertFalse($service->autoLoginFromCookie());
}
// ---------------------------------------------------------------
// autoLoginFromCookie — selector not found in DB
// ---------------------------------------------------------------
public function testAutoLoginClearsCookieWhenSelectorNotFoundInDb(): void
{
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnMap([
['user', [], []],
]);
$cookie = $this->createMock(CookieStoreInterface::class);
$cookie->method('get')->willReturn('selector:token');
$cookie->expects($this->once())->method('remove');
$tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
$tokenRepo->method('findBySelector')->willReturn(null);
$service = $this->newService(sessionStore: $session, cookieStore: $cookie, tokenRepository: $tokenRepo);
$this->assertFalse($service->autoLoginFromCookie());
}
// ---------------------------------------------------------------
// autoLoginFromCookie — expired token
// ---------------------------------------------------------------
public function testAutoLoginDeletesTokenAndClearsCookieWhenExpired(): void
{
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnMap([
['user', [], []],
]);
$cookie = $this->createMock(CookieStoreInterface::class);
$cookie->method('get')->willReturn('selector:token');
$cookie->expects($this->once())->method('remove');
$tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
$tokenRepo->method('findBySelector')->willReturn([
'id' => 10,
'user_id' => 5,
'token_hash' => hash('sha256', 'token'),
'expires_at' => gmdate('Y-m-d H:i:s', time() - 3600),
]);
$tokenRepo->expects($this->once())->method('deleteById')->with(10);
$service = $this->newService(sessionStore: $session, cookieStore: $cookie, tokenRepository: $tokenRepo);
$this->assertFalse($service->autoLoginFromCookie());
}
// ---------------------------------------------------------------
// autoLoginFromCookie — invalid token hash (tampered)
// ---------------------------------------------------------------
public function testAutoLoginDeletesTokenWhenHashMismatch(): void
{
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnMap([
['user', [], []],
]);
$cookie = $this->createMock(CookieStoreInterface::class);
$cookie->method('get')->willReturn('selector:wrong-token');
$cookie->expects($this->once())->method('remove');
$tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
$tokenRepo->method('findBySelector')->willReturn([
'id' => 10,
'user_id' => 5,
'token_hash' => hash('sha256', 'correct-token'),
'expires_at' => gmdate('Y-m-d H:i:s', time() + 3600),
]);
$tokenRepo->expects($this->once())->method('deleteById')->with(10);
$service = $this->newService(sessionStore: $session, cookieStore: $cookie, tokenRepository: $tokenRepo);
$this->assertFalse($service->autoLoginFromCookie());
}
// ---------------------------------------------------------------
// autoLoginFromCookie — user not found or inactive
// ---------------------------------------------------------------
public function testAutoLoginDeletesTokenWhenUserNotFound(): void
{
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnMap([
['user', [], []],
]);
$cookie = $this->createMock(CookieStoreInterface::class);
$cookie->method('get')->willReturn('selector:token');
$cookie->expects($this->once())->method('remove');
$tokenHash = hash('sha256', 'token');
$tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
$tokenRepo->method('findBySelector')->willReturn([
'id' => 10,
'user_id' => 5,
'token_hash' => $tokenHash,
'expires_at' => gmdate('Y-m-d H:i:s', time() + 3600),
]);
$tokenRepo->expects($this->once())->method('deleteById')->with(10);
$userRead = $this->createMock(UserReadRepositoryInterface::class);
$userRead->method('find')->willReturn(null);
$service = $this->newService(
sessionStore: $session,
cookieStore: $cookie,
tokenRepository: $tokenRepo,
userReadRepository: $userRead
);
$this->assertFalse($service->autoLoginFromCookie());
}
public function testAutoLoginDeletesTokenWhenUserInactive(): void
{
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnMap([
['user', [], []],
]);
$cookie = $this->createMock(CookieStoreInterface::class);
$cookie->method('get')->willReturn('selector:token');
$cookie->expects($this->once())->method('remove');
$tokenHash = hash('sha256', 'token');
$tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
$tokenRepo->method('findBySelector')->willReturn([
'id' => 10,
'user_id' => 5,
'token_hash' => $tokenHash,
'expires_at' => gmdate('Y-m-d H:i:s', time() + 3600),
]);
$tokenRepo->expects($this->once())->method('deleteById')->with(10);
$userRead = $this->createMock(UserReadRepositoryInterface::class);
$userRead->method('find')->willReturn(['id' => 5, 'active' => 0]);
$service = $this->newService(
sessionStore: $session,
cookieStore: $cookie,
tokenRepository: $tokenRepo,
userReadRepository: $userRead
);
$this->assertFalse($service->autoLoginFromCookie());
}
// ---------------------------------------------------------------
// autoLoginFromCookie — success with token rotation
// ---------------------------------------------------------------
public function testAutoLoginSucceedsAndRotatesToken(): void
{
$_SESSION = [];
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnMap([
['user', [], []],
['no_active_tenant', false, false],
]);
$session->expects($this->once())->method('set');
$cookie = $this->createMock(CookieStoreInterface::class);
$cookie->method('get')->willReturn('selector:token');
$cookie->expects($this->once())->method('set')
->with('remember', $this->callback(fn (string $v): bool => str_starts_with($v, 'selector:')), $this->anything());
$tokenHash = hash('sha256', 'token');
$tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
$tokenRepo->method('findBySelector')->willReturn([
'id' => 10,
'user_id' => 5,
'token_hash' => $tokenHash,
'expires_at' => gmdate('Y-m-d H:i:s', time() + 86400),
]);
$tokenRepo->expects($this->once())->method('updateToken')
->with(10, $this->callback(fn (string $h): bool => strlen($h) === 64), $this->anything());
$tokenRepo->expects($this->never())->method('deleteById');
$userRead = $this->createMock(UserReadRepositoryInterface::class);
$userRead->method('find')->willReturn(['id' => 5, 'active' => 1]);
$permGateway = $this->createMock(AuthPermissionGateway::class);
$permGateway->expects($this->once())->method('warmUserPermissions')->with(5, true);
$tenantCtx = $this->createMock(AuthSessionTenantContextService::class);
$tenantCtx->expects($this->once())->method('hydrateForUser')->with(5);
$userWrite = $this->createMock(UserWriteRepositoryInterface::class);
$userWrite->expects($this->once())->method('updateLastLogin')->with(5, 'local');
$service = $this->newService(
sessionStore: $session,
cookieStore: $cookie,
tokenRepository: $tokenRepo,
userReadRepository: $userRead,
userWriteRepository: $userWrite,
permissionGateway: $permGateway,
authSessionTenantContextService: $tenantCtx
);
$this->assertTrue($service->autoLoginFromCookie());
}
// ---------------------------------------------------------------
// autoLoginFromCookie — no active tenant triggers logout
// ---------------------------------------------------------------
public function testAutoLoginLogsOutWhenNoActiveTenant(): void
{
$_SESSION = [];
$session = $this->createMock(SessionStoreInterface::class);
$session->method('get')->willReturnCallback(function (string $key, mixed $default) {
if ($key === 'user') {
return [];
}
if ($key === 'no_active_tenant') {
return true;
}
return $default;
});
$cookie = $this->createMock(CookieStoreInterface::class);
$cookie->method('get')->willReturn('selector:token');
$tokenHash = hash('sha256', 'token');
$tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
$tokenRepo->method('findBySelector')->willReturn([
'id' => 10,
'user_id' => 5,
'token_hash' => $tokenHash,
'expires_at' => gmdate('Y-m-d H:i:s', time() + 86400),
]);
$userRead = $this->createMock(UserReadRepositoryInterface::class);
$userRead->method('find')->willReturn(['id' => 5, 'active' => 1]);
$service = $this->newService(
sessionStore: $session,
cookieStore: $cookie,
tokenRepository: $tokenRepo,
userReadRepository: $userRead
);
$this->assertFalse($service->autoLoginFromCookie());
}
// ---------------------------------------------------------------
// forgetCurrentUser
// ---------------------------------------------------------------
public function testForgetCurrentUserDeletesTokenAndClearsCookie(): void
{
$cookie = $this->createMock(CookieStoreInterface::class);
$cookie->method('get')->willReturn('sel:tok');
$cookie->expects($this->once())->method('remove');
$tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
$tokenRepo->method('findBySelector')->willReturn(['id' => 7]);
$tokenRepo->expects($this->once())->method('deleteById')->with(7);
$service = $this->newService(cookieStore: $cookie, tokenRepository: $tokenRepo);
$service->forgetCurrentUser();
}
public function testForgetCurrentUserDoesNothingWhenNoCookie(): void
{
$cookie = $this->createMock(CookieStoreInterface::class);
$cookie->method('get')->willReturn('');
$cookie->expects($this->once())->method('remove');
$tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
$tokenRepo->expects($this->never())->method('findBySelector');
$tokenRepo->expects($this->never())->method('deleteById');
$service = $this->newService(cookieStore: $cookie, tokenRepository: $tokenRepo);
$service->forgetCurrentUser();
}
// ---------------------------------------------------------------
// forgetAllForUser
// ---------------------------------------------------------------
public function testForgetAllForUserDelegatesToRepository(): void
{
$tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
$tokenRepo->expects($this->once())->method('deleteByUserId')->with(42);
$service = $this->newService(tokenRepository: $tokenRepo);
$service->forgetAllForUser(42);
}
// ---------------------------------------------------------------
// expireAllTokensByAdmin
// ---------------------------------------------------------------
public function testExpireAllTokensByAdminDelegatesToRepository(): void
{
$tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
$tokenRepo->expects($this->once())->method('expireAllByAdmin')->willReturn(15);
$service = $this->newService(tokenRepository: $tokenRepo);
$this->assertSame(15, $service->expireAllTokensByAdmin());
}
// ---------------------------------------------------------------
// Helper
// ---------------------------------------------------------------
private function newService(
?UserReadRepositoryInterface $userReadRepository = null,
?UserWriteRepositoryInterface $userWriteRepository = null,
?RememberTokenRepositoryInterface $tokenRepository = null,
?AuthPermissionGateway $permissionGateway = null,
?AuthSessionTenantContextService $authSessionTenantContextService = null,
?SessionStoreInterface $sessionStore = null,
?CookieStoreInterface $cookieStore = null,
?RequestRuntimeInterface $requestRuntime = null
): RememberMeService {
// RequestRuntimeInterface has a method named "method()" which PHPUnit 12
// cannot mock (reserved name). Use a simple anonymous-class stub instead.
$requestRuntime ??= new class () implements RequestRuntimeInterface {
public function method(): string
{
return 'GET';
}
public function path(): string
{
return '/';
}
public function ip(): string
{
return '127.0.0.1';
}
public function userAgent(): string
{
return 'test';
}
public function queryParams(): array
{
return [];
}
public function isSecure(): bool
{
return false;
}
};
return new RememberMeService(
$userReadRepository ?? $this->createMock(UserReadRepositoryInterface::class),
$userWriteRepository ?? $this->createMock(UserWriteRepositoryInterface::class),
$tokenRepository ?? $this->createMock(RememberTokenRepositoryInterface::class),
$permissionGateway ?? $this->createMock(AuthPermissionGateway::class),
$authSessionTenantContextService ?? $this->createMock(AuthSessionTenantContextService::class),
$sessionStore ?? $this->createMock(SessionStoreInterface::class),
$cookieStore ?? $this->createMock(CookieStoreInterface::class),
$requestRuntime
);
}
}