Merge pull request 'Redirect to login/ when refresh token is invalid' (#6) from dev into main

This commit was merged in pull request #6.
This commit is contained in:
2026-08-21 23:02:03 +02:00
4 changed files with 84 additions and 15 deletions
+47 -1
View File
@@ -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<typeof fetch>().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<typeof fetch>().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({
+16 -1
View File
@@ -1,8 +1,17 @@
import { useAuthStore } from '@/stores/auth'
import { API_BASE_URL } from '@/api/auth'
import router from '@/router'
let refreshPromise: Promise<unknown> | null = null
async function redirectToLogin(): Promise<void> {
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
}
}
+9 -5
View File
@@ -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<typeof import('vue-router')>()
return {
...actual,
useRouter: () => ({
push: pushMock,
}),
}
})
describe('ListDetailView', () => {
beforeEach(() => {
+12 -8
View File
@@ -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<typeof import('vue-router')>()
return {
...actual,
useRouter: () => ({
push: mockPush,
}),
useRoute: () => ({
query: {},
}),
}
})
describe('LoginView', () => {
beforeEach(() => {