From 89f448bd423ec0629838df928f5dc27b74752ad4 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 3 Aug 2026 19:41:51 +0300 Subject: [PATCH 1/8] test logout flow --- .../tests/Feature/Auth/LogoutEndpointTest.php | 58 +++++++++++++++++++ .../tests/Unit/Auth/UseCases/LogoutTest.php | 58 +++++++++++++++++++ .../Http/Controllers/AuthControllerTest.php | 34 +++++++++++ 3 files changed, 150 insertions(+) create mode 100644 backend/tests/Feature/Auth/LogoutEndpointTest.php create mode 100644 backend/tests/Unit/Auth/UseCases/LogoutTest.php diff --git a/backend/tests/Feature/Auth/LogoutEndpointTest.php b/backend/tests/Feature/Auth/LogoutEndpointTest.php new file mode 100644 index 0000000..3986cb9 --- /dev/null +++ b/backend/tests/Feature/Auth/LogoutEndpointTest.php @@ -0,0 +1,58 @@ +create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: 'hashed-password', + )); + app(SessionRepository::class)->create(new CreateSessionDto( + token: 'session-token', + user: $user, + createdAt: $now, + expiresAt: $now->modify('+7 days'), + )); + + $response = $this->withCredentials() + ->withUnencryptedCookie( + AuthMiddleware::COOKIE_NAME, + 'session-token', + )->postJson('/api/logout'); + + $response->assertNoContent(); + $response->assertCookieExpired(AuthMiddleware::COOKIE_NAME); + $this->assertNull( + app(SessionRepository::class)->findByToken('session-token'), + ); + } + + public function test_logout_rejects_a_request_without_a_cookie(): void + { + $response = $this->postJson('/api/logout'); + + $response + ->assertStatus(401) + ->assertExactJson(['error' => 'unauthenticated']); + } +} diff --git a/backend/tests/Unit/Auth/UseCases/LogoutTest.php b/backend/tests/Unit/Auth/UseCases/LogoutTest.php new file mode 100644 index 0000000..5accde6 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/LogoutTest.php @@ -0,0 +1,58 @@ +sessionRepository = new FakeSessionRepository; + $this->useCase = new Logout($this->sessionRepository); + } + + public function test_existing_token_session_is_removed(): void + { + $now = new DateTimeImmutable( + '2026-07-31T12:00:00', + new DateTimeZone('UTC'), + ); + $this->sessionRepository->create(new CreateSessionDto( + token: 'session-token', + user: new User( + id: 7, + email: new EmailAddress('user@example.com'), + passwordHash: 'hashed-password', + ), + createdAt: $now, + expiresAt: $now->modify('+7 days'), + )); + + $this->useCase->execute('session-token'); + + $this->assertNull( + $this->sessionRepository->findByToken('session-token'), + ); + } + + public function test_unknown_token_does_not_throw(): void + { + $this->useCase->execute('unknown-token'); + + $this->assertNull( + $this->sessionRepository->findByToken('unknown-token'), + ); + } +} diff --git a/backend/tests/Unit/Http/Controllers/AuthControllerTest.php b/backend/tests/Unit/Http/Controllers/AuthControllerTest.php index cded75c..5372272 100644 --- a/backend/tests/Unit/Http/Controllers/AuthControllerTest.php +++ b/backend/tests/Unit/Http/Controllers/AuthControllerTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\Http\Controllers; use App\Auth\UseCases\AuthenticateUser\AuthenticateUser; use App\Auth\UseCases\CreateSession\CreateSession; +use App\Auth\UseCases\Logout\Logout; use App\Http\Controllers\AuthController; use App\Http\Middleware\AuthMiddleware; use App\Shared\ValueObject\EmailAddress; @@ -45,9 +46,11 @@ class AuthControllerTest extends TestCase new DateTimeZone('UTC'), )), ); + $logout = new Logout($this->sessionRepository); $this->controller = new AuthController( $authenticateUser, $createSession, + $logout, ); } @@ -117,6 +120,37 @@ class AuthControllerTest extends TestCase ); } + public function test_logout_deletes_session_and_clears_cookie(): void + { + $this->createUser('correct-password'); + $this->controller->login(new Request([ + 'email' => 'user@example.com', + 'password' => 'correct-password', + ])); + $request = new Request; + $request->cookies->set( + AuthMiddleware::COOKIE_NAME, + 'session-token', + ); + + $response = $this->controller->logout($request); + + $this->assertSame(204, $response->getStatusCode()); + $this->assertNull( + $this->sessionRepository->findByToken('session-token'), + ); + $cookies = $response->headers->getCookies(); + $this->assertCount(1, $cookies); + $this->assertSame( + AuthMiddleware::COOKIE_NAME, + $cookies[0]->getName(), + ); + $this->assertSame('', $cookies[0]->getValue()); + $this->assertSame(1, $cookies[0]->getExpiresTime()); + $this->assertTrue($cookies[0]->isHttpOnly()); + $this->assertSame('lax', $cookies[0]->getSameSite()); + } + private function createUser(string $password): void { $this->userRepository->create(new CreateUserDto( From 22d9ad51361b09c5baf7a38424f9fa3436a57bac Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 3 Aug 2026 19:42:45 +0300 Subject: [PATCH 2/8] add logout endpoint --- backend/app/Auth/UseCases/Logout/Logout.php | 17 +++++++++++++ .../app/Http/Controllers/AuthController.php | 24 +++++++++++++++++++ backend/routes/api.php | 1 + 3 files changed, 42 insertions(+) create mode 100644 backend/app/Auth/UseCases/Logout/Logout.php diff --git a/backend/app/Auth/UseCases/Logout/Logout.php b/backend/app/Auth/UseCases/Logout/Logout.php new file mode 100644 index 0000000..3cede1f --- /dev/null +++ b/backend/app/Auth/UseCases/Logout/Logout.php @@ -0,0 +1,17 @@ +sessionRepository->deleteByToken($token); + } +} diff --git a/backend/app/Http/Controllers/AuthController.php b/backend/app/Http/Controllers/AuthController.php index a9c1960..4fdaea2 100644 --- a/backend/app/Http/Controllers/AuthController.php +++ b/backend/app/Http/Controllers/AuthController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Auth\UseCases\AuthenticateUser\AuthenticateUser; use App\Auth\UseCases\AuthenticateUser\AuthenticateUserRequest; use App\Auth\UseCases\CreateSession\CreateSession; +use App\Auth\UseCases\Logout\Logout; use App\Exceptions\BadRequestException; use App\Exceptions\UnauthorizedException; use App\Http\Middleware\AuthMiddleware; @@ -19,6 +20,7 @@ class AuthController extends Controller public function __construct( private AuthenticateUser $authenticateUser, private CreateSession $createSession, + private Logout $logout, ) {} public function login(Request $request): JsonResponse @@ -71,6 +73,28 @@ class AuthController extends Controller ]); } + public function logout(Request $request): JsonResponse + { + $token = $request->cookie(AuthMiddleware::COOKIE_NAME); + if (is_string($token) && $token !== '') { + $this->logout->execute($token); + } + + $response = new JsonResponse(null, 204); + + return $response->withCookie(Cookie::create( + name: AuthMiddleware::COOKIE_NAME, + value: '', + expire: 1, + path: '/', + domain: null, + secure: false, + httpOnly: true, + raw: false, + sameSite: Cookie::SAMESITE_LAX, + )); + } + /** * @return array{id: int, email: string} */ diff --git a/backend/routes/api.php b/backend/routes/api.php index d2e63d8..7d7d7c1 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -8,4 +8,5 @@ Route::post('/login', [AuthController::class, 'login']); Route::middleware(AuthMiddleware::class)->group(function (): void { Route::get('/me', [AuthController::class, 'me']); + Route::post('/logout', [AuthController::class, 'logout']); }); From d457c766c3fb8113ec291782f931820bf2a3bdfa Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 3 Aug 2026 19:45:09 +0300 Subject: [PATCH 3/8] test frontend logout --- .../website/cypress/e2e/session-auth.cy.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/frontend/website/cypress/e2e/session-auth.cy.ts b/frontend/website/cypress/e2e/session-auth.cy.ts index d7fb723..caa7956 100644 --- a/frontend/website/cypress/e2e/session-auth.cy.ts +++ b/frontend/website/cypress/e2e/session-auth.cy.ts @@ -3,6 +3,40 @@ const authenticatedUser = { email: 'user@example.com', } +function interceptLogoutFlow(): void { + let authenticated = true + + cy.intercept('GET', '**/api/me', (request) => { + if (authenticated) { + request.alias = 'me' + request.reply({ + statusCode: 200, + body: { user: authenticatedUser }, + }) + return + } + + request.alias = 'loggedOutMe' + request.reply({ + statusCode: 401, + body: { error: 'unauthenticated' }, + }) + }) + cy.intercept('POST', '**/api/logout', (request) => { + expect(request.headers.accept).to.equal('application/json') + authenticated = false + request.reply({ statusCode: 204 }) + }).as('logout') +} + +function visitDashboardAndLogout(): void { + cy.visit('/dashboard') + cy.wait('@me') + cy.contains('button', 'Log out').click() + cy.wait('@logout') + cy.wait('@loggedOutMe') +} + describe('session authentication', () => { it('restores an authenticated session on a protected route', () => { cy.intercept('GET', '**/api/me', { @@ -53,4 +87,23 @@ describe('session authentication', () => { cy.location('pathname').should('equal', '/login') }) + + it('logs out and redirects to login', () => { + interceptLogoutFlow() + + visitDashboardAndLogout() + + cy.location('pathname').should('equal', '/login') + }) + + it('keeps protected routes inaccessible after logout', () => { + interceptLogoutFlow() + visitDashboardAndLogout() + + cy.visit('/dashboard') + cy.wait('@loggedOutMe') + + cy.location('pathname').should('equal', '/login') + cy.location('search').should('include', 'redirect=/dashboard') + }) }) From c15c13fd3883f6a17a152cef4db29fba3d485649 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 3 Aug 2026 19:46:33 +0300 Subject: [PATCH 4/8] add frontend logout --- frontend/website/src/stores/auth.ts | 15 +++++++ frontend/website/src/views/DashboardView.vue | 43 ++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/frontend/website/src/stores/auth.ts b/frontend/website/src/stores/auth.ts index d720f18..fc9eef3 100644 --- a/frontend/website/src/stores/auth.ts +++ b/frontend/website/src/stores/auth.ts @@ -101,6 +101,20 @@ export const useAuthStore = defineStore('auth', () => { } } + async function logout(): Promise { + try { + await fetch(`${API_BASE}/api/logout`, { + method: 'POST', + credentials: 'include', + headers: { + Accept: 'application/json', + }, + }) + } finally { + user.value = null + } + } + return { user, loading, @@ -108,5 +122,6 @@ export const useAuthStore = defineStore('auth', () => { isAuthenticated, fetchMe, login, + logout, } }) diff --git a/frontend/website/src/views/DashboardView.vue b/frontend/website/src/views/DashboardView.vue index 90b1b1d..c61783f 100644 --- a/frontend/website/src/views/DashboardView.vue +++ b/frontend/website/src/views/DashboardView.vue @@ -1,11 +1,23 @@