From 8c35899bd93b2ff939dad9edfa07d00f159b1103 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Fri, 21 Aug 2026 23:01:32 +0200 Subject: [PATCH] fix: redirect to login/ when refresh token is invalid --- src/api/__tests__/client.spec.ts | 48 +++++++++++++++++++++- src/api/client.ts | 17 +++++++- src/views/__tests__/ListDetailView.spec.ts | 14 ++++--- src/views/__tests__/LoginView.spec.ts | 20 +++++---- 4 files changed, 84 insertions(+), 15 deletions(-) diff --git a/src/api/__tests__/client.spec.ts b/src/api/__tests__/client.spec.ts index 35d6729..755c718 100644 --- a/src/api/__tests__/client.spec.ts +++ b/src/api/__tests__/client.spec.ts @@ -3,14 +3,16 @@ import { setActivePinia, createPinia } from 'pinia' import { fetchWithAuth, apiClient } from '../client' import { useAuthStore } from '@/stores/auth' import * as authApi from '@/api/auth' +import router from '@/router' describe('api client (fetchWithAuth)', () => { const originalFetch = global.fetch - beforeEach(() => { + beforeEach(async () => { setActivePinia(createPinia()) localStorage.clear() vi.restoreAllMocks() + await router.push('/') }) afterEach(() => { @@ -105,6 +107,50 @@ describe('api client (fetchWithAuth)', () => { expect(res).toBe(secondResponse) }) + it('redirects to login when a 401 occurs and no refresh token is available', async () => { + const authStore = useAuthStore() + authStore.setTokens({ + access_token: 'expired-token', + refresh_token: 'valid-refresh-token', + }) + authStore.clearTokens() + + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + } as unknown as Response) + global.fetch = fetchMock + + await fetchWithAuth('/lists') + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(router.currentRoute.value.name).toBe('login') + }) + + it('redirects to login when refreshing the token fails', async () => { + const authStore = useAuthStore() + authStore.setTokens({ + access_token: 'expired-token', + refresh_token: 'expired-refresh-token', + }) + + vi.spyOn(authApi, 'refreshApi').mockRejectedValueOnce(new Error('Refresh token expired')) + + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + } as unknown as Response) + global.fetch = fetchMock + + await fetchWithAuth('/lists') + + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(authStore.accessToken).toBeNull() + expect(router.currentRoute.value.name).toBe('login') + }) + it('calls apiClient helper methods correctly', async () => { const authStore = useAuthStore() authStore.setTokens({ diff --git a/src/api/client.ts b/src/api/client.ts index 8e62c46..d7ed06a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,8 +1,17 @@ import { useAuthStore } from '@/stores/auth' import { API_BASE_URL } from '@/api/auth' +import router from '@/router' let refreshPromise: Promise | null = null +async function redirectToLogin(): Promise { + const currentRoute = router.currentRoute.value + if (currentRoute.name === 'login') { + return + } + await router.push({ name: 'login', query: { redirect: currentRoute.fullPath } }) +} + export interface FetchOptions extends RequestInit { skipAuth?: boolean skipRefresh?: boolean @@ -43,7 +52,12 @@ export async function fetchWithAuth( headers: buildHeaders(customOptions, skipAuth ? null : authStore.accessToken), }) - if (response.status === 401 && !skipRefresh && authStore.refreshToken) { + if (response.status === 401 && !skipRefresh) { + if (!authStore.refreshToken) { + await redirectToLogin() + return response + } + try { if (!refreshPromise) { refreshPromise = authStore.refreshTokens().finally(() => { @@ -57,6 +71,7 @@ export async function fetchWithAuth( headers: buildHeaders(customOptions, authStore.accessToken), }) } catch { + await redirectToLogin() return response } } diff --git a/src/views/__tests__/ListDetailView.spec.ts b/src/views/__tests__/ListDetailView.spec.ts index d578e62..bdcd134 100644 --- a/src/views/__tests__/ListDetailView.spec.ts +++ b/src/views/__tests__/ListDetailView.spec.ts @@ -7,11 +7,15 @@ import type { LocalList } from '@/database/db' const pushMock = vi.fn<(to: string) => void>() -vi.mock('vue-router', () => ({ - useRouter: () => ({ - push: pushMock, - }), -})) +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useRouter: () => ({ + push: pushMock, + }), + } +}) describe('ListDetailView', () => { beforeEach(() => { diff --git a/src/views/__tests__/LoginView.spec.ts b/src/views/__tests__/LoginView.spec.ts index fa8bc05..6143a7d 100644 --- a/src/views/__tests__/LoginView.spec.ts +++ b/src/views/__tests__/LoginView.spec.ts @@ -5,14 +5,18 @@ import LoginView from '../LoginView.vue' import { useAuthStore } from '@/stores/auth' const mockPush = vi.fn<(to: string) => void>() -vi.mock('vue-router', () => ({ - useRouter: () => ({ - push: mockPush, - }), - useRoute: () => ({ - query: {}, - }), -})) +vi.mock('vue-router', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useRouter: () => ({ + push: mockPush, + }), + useRoute: () => ({ + query: {}, + }), + } +}) describe('LoginView', () => { beforeEach(() => { -- 2.54.0