fix: redirect to login/ when refresh token is invalid
This commit is contained in:
@@ -3,14 +3,16 @@ import { setActivePinia, createPinia } from 'pinia'
|
|||||||
import { fetchWithAuth, apiClient } from '../client'
|
import { fetchWithAuth, apiClient } from '../client'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import * as authApi from '@/api/auth'
|
import * as authApi from '@/api/auth'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
describe('api client (fetchWithAuth)', () => {
|
describe('api client (fetchWithAuth)', () => {
|
||||||
const originalFetch = global.fetch
|
const originalFetch = global.fetch
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
vi.restoreAllMocks()
|
vi.restoreAllMocks()
|
||||||
|
await router.push('/')
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -105,6 +107,50 @@ describe('api client (fetchWithAuth)', () => {
|
|||||||
expect(res).toBe(secondResponse)
|
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 () => {
|
it('calls apiClient helper methods correctly', async () => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
authStore.setTokens({
|
authStore.setTokens({
|
||||||
|
|||||||
+16
-1
@@ -1,8 +1,17 @@
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { API_BASE_URL } from '@/api/auth'
|
import { API_BASE_URL } from '@/api/auth'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
let refreshPromise: Promise<unknown> | null = null
|
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 {
|
export interface FetchOptions extends RequestInit {
|
||||||
skipAuth?: boolean
|
skipAuth?: boolean
|
||||||
skipRefresh?: boolean
|
skipRefresh?: boolean
|
||||||
@@ -43,7 +52,12 @@ export async function fetchWithAuth(
|
|||||||
headers: buildHeaders(customOptions, skipAuth ? null : authStore.accessToken),
|
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 {
|
try {
|
||||||
if (!refreshPromise) {
|
if (!refreshPromise) {
|
||||||
refreshPromise = authStore.refreshTokens().finally(() => {
|
refreshPromise = authStore.refreshTokens().finally(() => {
|
||||||
@@ -57,6 +71,7 @@ export async function fetchWithAuth(
|
|||||||
headers: buildHeaders(customOptions, authStore.accessToken),
|
headers: buildHeaders(customOptions, authStore.accessToken),
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
|
await redirectToLogin()
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,15 @@ import type { LocalList } from '@/database/db'
|
|||||||
|
|
||||||
const pushMock = vi.fn<(to: string) => void>()
|
const pushMock = vi.fn<(to: string) => void>()
|
||||||
|
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('vue-router')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: pushMock,
|
push: pushMock,
|
||||||
}),
|
}),
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
|
|
||||||
describe('ListDetailView', () => {
|
describe('ListDetailView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ import LoginView from '../LoginView.vue'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const mockPush = vi.fn<(to: string) => void>()
|
const mockPush = vi.fn<(to: string) => void>()
|
||||||
vi.mock('vue-router', () => ({
|
vi.mock('vue-router', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('vue-router')>()
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
push: mockPush,
|
push: mockPush,
|
||||||
}),
|
}),
|
||||||
useRoute: () => ({
|
useRoute: () => ({
|
||||||
query: {},
|
query: {},
|
||||||
}),
|
}),
|
||||||
}))
|
}
|
||||||
|
})
|
||||||
|
|
||||||
describe('LoginView', () => {
|
describe('LoginView', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user