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]); $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()); $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()); } // --------------------------------------------------------------- // 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 ); } }