profileShare

rasmusjy / splitapp-frontend-vue

Read-only snapshot

No repository description.

main default branch 96 files Expires Sep 13, 2026, 9:06 AM
token-refresh.spec.ts 5,135 bytes
1 /**
2 * Integration test: 401 → refresh → retry pipeline in httpClient.
3 *
4 * This exercises the most security-sensitive glue in the app:
5 * - request interceptor attaches Authorization header
6 * - response interceptor catches 401, calls AccountService.refreshTokenAsync,
7 * updates the auth store, and replays the original request
8 * - on refresh failure: calls AccountService.logoutAsync, wipes store,
9 * routes to /login
10 *
11 * We use MSW to intercept axios requests at the network layer so axios
12 * behaves exactly as in production — no method mocking.
13 */
14
15 import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'
16 import { setupServer } from 'msw/node'
17 import { http, HttpResponse } from 'msw'
18 import { setActivePinia, createPinia } from 'pinia'
19
20 // Mock the router BEFORE importing httpClient — httpClient imports it eagerly
21 // and we need to assert .push() was called on 401-with-no-recovery.
22 // vi.mock is hoisted to the top of the file, so the factory cannot reference
23 // any lexical variable declared below. vi.hoisted() makes the spy available.
24 const { routerPush } = vi.hoisted(() => ({ routerPush: vi.fn() }))
25 vi.mock('@/router', () => ({
26 default: { push: routerPush },
27 }))
28
29 // Now import modules under test (after the mock is set up).
30 import { useAuthStore } from '@/stores/auth'
31 import httpClient from '@/services/httpClient'
32
33 const API = 'http://test.local/api/v1'
34
35 // Track hits so each test can assert how many times a given endpoint was called.
36 const hits = {
37 trips: 0,
38 refresh: 0,
39 logout: 0,
40 }
41
42 const server = setupServer(
43 http.get(`${API}/trips`, ({ request }) => {
44 hits.trips++
45 const auth = request.headers.get('authorization')
46 if (auth === 'Bearer good-new-jwt') {
47 return HttpResponse.json([{ id: 't1', name: 'Barcelona' }])
48 }
49 // Default: first request with stale jwt → 401
50 return new HttpResponse(null, { status: 401 })
51 }),
52
53 http.post(`${API}/identity/Account/RefreshTokenData`, () => {
54 hits.refresh++
55 return HttpResponse.json({
56 jwt: 'good-new-jwt',
57 refreshToken: 'good-new-refresh',
58 firstName: 'Alice',
59 lastName: 'Alpha',
60 })
61 }),
62
63 http.post(`${API}/identity/Account/Logout`, () => {
64 hits.logout++
65 return new HttpResponse(null, { status: 200 })
66 }),
67 )
68
69 beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
70 afterAll(() => server.close())
71
72 beforeEach(() => {
73 setActivePinia(createPinia())
74 hits.trips = 0
75 hits.refresh = 0
76 hits.logout = 0
77 routerPush.mockClear()
78 })
79 afterEach(() => server.resetHandlers())
80
81 describe('httpClient — request interceptor', () => {
82 it('attaches the Authorization header from the auth store', async () => {
83 const store = useAuthStore()
84 store.jwt = 'good-new-jwt'
85
86 const res = await httpClient.get('trips')
87 expect(res.status).toBe(200)
88 expect(res.data).toEqual([{ id: 't1', name: 'Barcelona' }])
89 expect(hits.trips).toBe(1)
90 expect(hits.refresh).toBe(0)
91 })
92 })
93
94 describe('httpClient — 401 refresh-retry flow', () => {
95 it('refreshes the token and replays the original request on 401', async () => {
96 const store = useAuthStore()
97 store.jwt = 'stale-jwt'
98 store.refreshToken = 'good-refresh'
99
100 const res = await httpClient.get('trips')
101
102 // 2 trip calls: first 401, retry 200.
103 expect(hits.trips).toBe(2)
104 expect(hits.refresh).toBe(1)
105 expect(res.data).toEqual([{ id: 't1', name: 'Barcelona' }])
106
107 // Store was updated with the new tokens.
108 expect(store.jwt).toBe('good-new-jwt')
109 expect(store.refreshToken).toBe('good-new-refresh')
110 expect(store.userName).toBe('Alice Alpha')
111
112 // No logout, no redirect — recovery succeeded.
113 expect(hits.logout).toBe(0)
114 expect(routerPush).not.toHaveBeenCalled()
115 })
116
117 it('logs the user out and redirects when the refresh call fails', async () => {
118 // Override: refresh endpoint now returns 401 instead of new tokens.
119 server.use(
120 http.post(`${API}/identity/Account/RefreshTokenData`, () => {
121 hits.refresh++
122 return new HttpResponse(null, { status: 401 })
123 }),
124 )
125
126 const store = useAuthStore()
127 store.jwt = 'stale-jwt'
128 store.refreshToken = 'also-stale'
129
130 await expect(httpClient.get('trips')).rejects.toMatchObject({
131 response: { status: 401 },
132 })
133
134 // Store cleared, logout called, router redirected.
135 expect(store.jwt).toBeNull()
136 expect(store.refreshToken).toBeNull()
137 expect(store.userName).toBeNull()
138 expect(hits.logout).toBe(1)
139 expect(routerPush).toHaveBeenCalledWith({ name: 'Login' })
140 })
141
142 it('does not attempt refresh when no refresh token is present', async () => {
143 const store = useAuthStore()
144 store.jwt = 'stale-jwt'
145 // store.refreshToken stays null
146
147 await expect(httpClient.get('trips')).rejects.toMatchObject({
148 response: { status: 401 },
149 })
150
151 expect(hits.refresh).toBe(0)
152 expect(hits.logout).toBe(0)
153 // logout() is still called client-side and router redirected.
154 expect(store.jwt).toBeNull()
155 expect(routerPush).toHaveBeenCalledWith({ name: 'Login' })
156 })
157 })
158