diff --git a/backend/tests/Fakes/FakeClock.php b/backend/tests/Fakes/FakeClock.php new file mode 100644 index 0000000..30374d3 --- /dev/null +++ b/backend/tests/Fakes/FakeClock.php @@ -0,0 +1,18 @@ +currentTime; + } +} diff --git a/backend/tests/Fakes/FakeSessionRepository.php b/backend/tests/Fakes/FakeSessionRepository.php new file mode 100644 index 0000000..d64efd5 --- /dev/null +++ b/backend/tests/Fakes/FakeSessionRepository.php @@ -0,0 +1,38 @@ + + */ + private array $sessions = []; + + public function create(CreateSessionDto $dto): Session + { + $session = new Session( + token: $dto->token, + user: $dto->user, + createdAt: $dto->createdAt, + expiresAt: $dto->expiresAt, + ); + $this->sessions[$dto->token] = $session; + + return $session; + } + + public function findByToken(string $token): ?Session + { + return $this->sessions[$token] ?? null; + } + + public function deleteByToken(string $token): void + { + unset($this->sessions[$token]); + } +} diff --git a/backend/tests/Feature/Auth/AuthMiddlewareTest.php b/backend/tests/Feature/Auth/AuthMiddlewareTest.php new file mode 100644 index 0000000..5b9c034 --- /dev/null +++ b/backend/tests/Feature/Auth/AuthMiddlewareTest.php @@ -0,0 +1,110 @@ +get( + '/test/authenticated-user', + function (Request $request): JsonResponse { + $user = $request->attributes->get('user'); + if (! $user instanceof User) { + return new JsonResponse(['error' => 'missing user'], 500); + } + + return new JsonResponse([ + 'id' => $user->getId(), + 'email' => $user->getEmail()->value(), + ]); + }, + ); + } + + public function test_valid_cookie_reaches_the_protected_route(): void + { + $user = $this->createUserAndSession( + token: 'valid-token', + expiresAt: $this->utc('2030-08-07T12:00:00'), + ); + + $response = $this->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'valid-token', + )->getJson('/test/authenticated-user'); + + $response->assertOk()->assertExactJson([ + 'id' => $user->getId(), + 'email' => 'user@example.com', + ]); + } + + public function test_missing_cookie_is_rejected(): void + { + $response = $this->getJson('/test/authenticated-user'); + + $response + ->assertStatus(401) + ->assertExactJson(['error' => 'unauthenticated']); + } + + public function test_expired_cookie_is_rejected_and_deleted(): void + { + $this->createUserAndSession( + token: 'expired-token', + expiresAt: $this->utc('2020-08-07T12:00:00'), + ); + + $response = $this->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'expired-token', + )->getJson('/test/authenticated-user'); + + $response->assertStatus(401); + $this->assertNull( + app(SessionRepository::class)->findByToken('expired-token'), + ); + } + + private function createUserAndSession( + string $token, + DateTimeImmutable $expiresAt, + ): User { + $user = app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + )); + app(SessionRepository::class)->create(new CreateSessionDto( + token: $token, + user: $user, + createdAt: $this->utc('2026-07-31T12:00:00'), + expiresAt: $expiresAt, + )); + + return $user; + } + + private function utc(string $time): DateTimeImmutable + { + return new DateTimeImmutable($time, new DateTimeZone('UTC')); + } +} diff --git a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php new file mode 100644 index 0000000..65908c7 --- /dev/null +++ b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php @@ -0,0 +1,159 @@ +now = new DateTimeImmutable( + '2026-07-31T12:00:00', + new DateTimeZone('UTC'), + ); + $this->sessionRepository = new FakeSessionRepository; + $this->middleware = new AuthMiddleware( + sessionRepository: $this->sessionRepository, + clock: new FakeClock($this->now), + ); + } + + public function test_missing_cookie_returns_unauthenticated(): void + { + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken(null), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame( + ['error' => 'unauthenticated'], + json_decode($response->getContent(), true), + ); + $this->assertNull($capturedRequest); + } + + public function test_empty_cookie_returns_unauthenticated(): void + { + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken(''), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($capturedRequest); + } + + public function test_unknown_token_returns_unauthenticated(): void + { + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken('unknown-token'), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($capturedRequest); + } + + public function test_expired_session_is_deleted(): void + { + $this->sessionRepository->create(new CreateSessionDto( + token: 'expired-token', + user: $this->user(), + createdAt: $this->now->modify('-8 days'), + expiresAt: $this->now->modify('-1 day'), + )); + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken('expired-token'), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($capturedRequest); + $this->assertNull( + $this->sessionRepository->findByToken('expired-token'), + ); + } + + public function test_valid_session_attaches_user_and_calls_next(): void + { + $user = $this->user(); + $this->sessionRepository->create(new CreateSessionDto( + token: 'valid-token', + user: $user, + createdAt: $this->now, + expiresAt: $this->now->modify('+7 days'), + )); + $capturedRequest = null; + + $response = $this->middleware->handle( + $this->requestWithToken('valid-token'), + $this->captureNextRequest($capturedRequest), + ); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertNotNull($capturedRequest); + $this->assertSame( + $user, + $capturedRequest->attributes->get('user'), + ); + } + + private function requestWithToken(?string $token): Request + { + $request = Request::create('/anything', 'GET'); + if ($token !== null) { + $request->cookies->set(AuthMiddleware::COOKIE_NAME, $token); + } + + return $request; + } + + /** + * @param Request|null $capturedRequest + * @return Closure(Request): JsonResponse + */ + private function captureNextRequest( + ?Request &$capturedRequest, + ): Closure { + return function (Request $request) use (&$capturedRequest) { + $capturedRequest = $request; + + return new JsonResponse(['ok' => true]); + }; + } + + private function user(): User + { + return new User( + id: 7, + email: new EmailAddress('user@example.com'), + ); + } +}