568 lines
22 KiB
PHP
568 lines
22 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\Access\PermissionService;
|
|
use MintyPHP\Service\Auth\AuthSessionTenantContextService;
|
|
use MintyPHP\Service\Auth\AuthSettingsGateway;
|
|
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);
|
|
$setKeys = [];
|
|
$session->method('get')->willReturnMap([
|
|
['user', [], []],
|
|
['no_active_tenant', false, false],
|
|
]);
|
|
$session->method('set')->willReturnCallback(function (string $key, mixed $value) use (&$setKeys): void {
|
|
$setKeys[] = $key;
|
|
});
|
|
|
|
$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]);
|
|
|
|
$permService = $this->createMock(PermissionService::class);
|
|
$permService->expects($this->once())->method('getUserPermissions')->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,
|
|
permissionService: $permService,
|
|
authSessionTenantContextService: $tenantCtx
|
|
);
|
|
$this->assertTrue($service->autoLoginFromCookie());
|
|
$this->assertContains('user', $setKeys);
|
|
$this->assertContains('session_started_at', $setKeys);
|
|
$this->assertContains('session_last_activity', $setKeys);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// 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());
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Dynamic TTL via AuthSettingsGateway
|
|
// ---------------------------------------------------------------
|
|
|
|
public function testRememberUserUsesConfiguredTtlFromGateway(): void
|
|
{
|
|
$settingsGateway = $this->createMock(AuthSettingsGateway::class);
|
|
$settingsGateway->method('getRememberTokenLifetimeDays')->willReturn(7);
|
|
|
|
$capturedExpiresAt = '';
|
|
$tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
|
|
$tokenRepo->expects($this->once())
|
|
->method('create')
|
|
->with(
|
|
42,
|
|
$this->anything(),
|
|
$this->anything(),
|
|
$this->callback(function (string $e) use (&$capturedExpiresAt): bool {
|
|
$capturedExpiresAt = $e;
|
|
return strtotime($e . ' UTC') > time();
|
|
})
|
|
)
|
|
->willReturn(1);
|
|
|
|
$cookieStore = $this->createMock(CookieStoreInterface::class);
|
|
$cookieStore->expects($this->once())
|
|
->method('set')
|
|
->with(
|
|
'remember',
|
|
$this->anything(),
|
|
$this->callback(fn (array $opts): bool => $opts['expires'] > time()
|
|
&& $opts['expires'] <= time() + (7 * 86400) + 10)
|
|
);
|
|
|
|
$service = $this->newService(
|
|
tokenRepository: $tokenRepo,
|
|
cookieStore: $cookieStore,
|
|
authSettingsGateway: $settingsGateway
|
|
);
|
|
$service->rememberUser(42);
|
|
|
|
// Verify expiration is ~7 days from now (not 30)
|
|
$expiresTimestamp = strtotime($capturedExpiresAt . ' UTC');
|
|
$this->assertGreaterThan(time() + (6 * 86400), $expiresTimestamp);
|
|
$this->assertLessThanOrEqual(time() + (7 * 86400) + 10, $expiresTimestamp);
|
|
}
|
|
|
|
public function testRememberUserFallsBackTo30DaysWhenNoGateway(): void
|
|
{
|
|
$capturedExpiresAt = '';
|
|
$tokenRepo = $this->createMock(RememberTokenRepositoryInterface::class);
|
|
$tokenRepo->expects($this->once())
|
|
->method('create')
|
|
->with(
|
|
42,
|
|
$this->anything(),
|
|
$this->anything(),
|
|
$this->callback(function (string $e) use (&$capturedExpiresAt): bool {
|
|
$capturedExpiresAt = $e;
|
|
return true;
|
|
})
|
|
)
|
|
->willReturn(1);
|
|
|
|
$cookieStore = $this->createMock(CookieStoreInterface::class);
|
|
$cookieStore->method('set');
|
|
|
|
$service = $this->newService(tokenRepository: $tokenRepo, cookieStore: $cookieStore);
|
|
$service->rememberUser(42);
|
|
|
|
$expiresTimestamp = strtotime($capturedExpiresAt . ' UTC');
|
|
$this->assertGreaterThan(time() + (29 * 86400), $expiresTimestamp);
|
|
$this->assertLessThanOrEqual(time() + (30 * 86400) + 10, $expiresTimestamp);
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Helper
|
|
// ---------------------------------------------------------------
|
|
|
|
private function newService(
|
|
?UserReadRepositoryInterface $userReadRepository = null,
|
|
?UserWriteRepositoryInterface $userWriteRepository = null,
|
|
?RememberTokenRepositoryInterface $tokenRepository = null,
|
|
?PermissionService $permissionService = null,
|
|
?AuthSessionTenantContextService $authSessionTenantContextService = null,
|
|
?SessionStoreInterface $sessionStore = null,
|
|
?CookieStoreInterface $cookieStore = null,
|
|
?RequestRuntimeInterface $requestRuntime = null,
|
|
?AuthSettingsGateway $authSettingsGateway = 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),
|
|
$permissionService ?? $this->createMock(PermissionService::class),
|
|
$authSessionTenantContextService ?? $this->createMock(AuthSessionTenantContextService::class),
|
|
$sessionStore ?? $this->createMock(SessionStoreInterface::class),
|
|
$cookieStore ?? $this->createMock(CookieStoreInterface::class),
|
|
$requestRuntime,
|
|
$authSettingsGateway
|
|
);
|
|
}
|
|
}
|