// Seam tests for src/stores/auth.js — the Pinia auth store. // // First Pinia-using spec in the project. Mocks vue-router, authService, // lanAccess, and apiClient. Drives the response interceptor's eight // branches (including the LAN GET-bypass in both the retry-already-set // path and the refresh-failed path) which are 100% uncovered elsewhere. import { describe, it, expect, vi, beforeEach } from 'vitest' import { setActivePinia, createPinia } from 'pinia' // Shared spy for router.push — captured via vi.hoisted so it's available // to the vi.mock factory before any imports are resolved. const routerPush = vi.hoisted(() => vi.fn()) vi.mock('vue-router', () => ({ useRouter: () => ({ push: routerPush }) })) vi.mock('@/api/authService.js', () => ({ login: vi.fn(), refreshAccessToken: vi.fn(), })) vi.mock('@/utils/lanAccess.js', () => ({ isLanAccess: vi.fn(() => false) })) // Mock apiClient with a vi.fn() so it's both callable (apiClient(config)) // and carries the method bag + interceptor slots. We capture response // handlers by pushing into a closure array on .use(). const { apiClient, responseHandlers, requestHandlers } = vi.hoisted(() => { const responseHandlers = [] const requestHandlers = [] const ac = vi.fn() ac.get = vi.fn() ac.post = vi.fn() ac.patch = vi.fn() ac.delete = vi.fn() ac.interceptors = { request: { use: vi.fn((fulfilled) => requestHandlers.push(fulfilled)), }, response: { use: vi.fn((fulfilled, rejected) => responseHandlers.push({ fulfilled, rejected }), ), }, } ac.defaults = { headers: { common: {} } } return { apiClient: ac, responseHandlers, requestHandlers } }) vi.mock('@/api/index.js', () => ({ default: apiClient })) // Must come after vi.mock so the auth store sees the mocks. import { useAuthStore } from '@/stores/auth.js' import { isLanAccess } from '@/utils/lanAccess.js' import { login as authLogin, refreshAccessToken as authRefresh } from '@/api/authService.js' import { makeJwt, expiredJwt, expiringSoonJwt } from '../utils/jwtFactory.js' // ─── Setup ───────────────────────────────────────────────────────── // // We manipulate jsdom's real `localStorage` directly (not a vi.stubGlobal // mock) because `stores/auth.js` reads `localStorage` at module-eval time // in its initial `ref(...)` values. A stub installed in beforeEach is too // late — by then the store's setup function has already snapshotted // jsdom's localStorage. Clearing jsdom's localStorage in beforeEach // gives us a clean slate without that trap. // // Vitest+jsdom also exposes `localStorage.removeItem` as a spy we can // use for logout-side-effect assertions. beforeEach(() => { setActivePinia(createPinia()) window.localStorage.clear() apiClient.defaults.headers.common = {} responseHandlers.length = 0 requestHandlers.length = 0 apiClient.mockReset() apiClient.mockResolvedValue({ data: 'retry-ok' }) apiClient.get.mockReset() apiClient.post.mockReset() apiClient.patch.mockReset() apiClient.delete.mockReset() routerPush.mockClear() authLogin.mockReset() authRefresh.mockReset() isLanAccess.mockReturnValue(false) }) // ─── Helpers ─────────────────────────────────────────────────────── const triggerResponseError = (error) => { const last = responseHandlers[responseHandlers.length - 1] return last.rejected(error) } const makeAxiosError = ({ status = 401, url = '/api/students/', method = 'get', _retry = false, data = { detail: 'x' }, } = {}) => ({ response: { status, data }, config: { url, method, headers: {}, _retry }, }) // ─── Token expiry (indirect decodeJwt coverage) ──────────────────── describe('token expiry (indirect decodeJwt coverage)', () => { it('isAccessTokenExpired returns false when no access token is set', () => { expect(useAuthStore().isAccessTokenExpired()).toBe(false) }) it('isAccessTokenExpired returns false for a valid token (exp in future)', () => { const auth = useAuthStore() auth.accessToken = makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600 }) expect(auth.isAccessTokenExpired()).toBe(false) }) it('isAccessTokenExpired returns true for an expired token', () => { const auth = useAuthStore() auth.accessToken = expiredJwt(-10) expect(auth.isAccessTokenExpired()).toBe(true) }) it('isAccessTokenExpired returns false when payload lacks an exp claim', () => { const auth = useAuthStore() // base64url encode a payload without `exp` const payload = btoa(JSON.stringify({ sub: 'no-exp' })) .replace(/=+$/, '') .replace(/\+/g, '-') .replace(/\//g, '_') auth.accessToken = `h.${payload}.s` expect(auth.isAccessTokenExpired()).toBe(false) }) it('isAccessTokenExpired returns false for a garbage token (decode fails)', () => { const auth = useAuthStore() auth.accessToken = 'not.a.jwt' expect(auth.isAccessTokenExpired()).toBe(false) }) it('isAccessTokenExpiringSoon returns true within the default 60s window', () => { const auth = useAuthStore() auth.accessToken = expiringSoonJwt(30) expect(auth.isAccessTokenExpiringSoon()).toBe(true) }) it('isAccessTokenExpiringSoon respects a custom threshold', () => { const auth = useAuthStore() auth.accessToken = makeJwt({ exp: Math.floor(Date.now() / 1000) + 100 }) expect(auth.isAccessTokenExpiringSoon(200)).toBe(true) expect(auth.isAccessTokenExpiringSoon(50)).toBe(false) }) }) // ─── isAuthenticated ─────────────────────────────────────────────── describe('isAuthenticated', () => { it('is false when accessToken is null', () => { expect(useAuthStore().isAuthenticated).toBe(false) }) it('is true when accessToken is set', () => { const auth = useAuthStore() auth.accessToken = 'any-non-empty' expect(auth.isAuthenticated).toBe(true) }) }) // ─── initializeAuth ──────────────────────────────────────────────── describe('initializeAuth', () => { it('restores accessToken, refreshToken, and user from localStorage', () => { localStorage.setItem('accessToken', 'A') localStorage.setItem('refreshToken', 'R') localStorage.setItem('user', JSON.stringify({ username: 'alice' })) const auth = useAuthStore() auth.initializeAuth() expect(auth.accessToken).toBe('A') expect(auth.refreshToken).toBe('R') expect(auth.user).toEqual({ username: 'alice' }) }) it('sets the axios Authorization header from the saved token', () => { localStorage.setItem('accessToken', 'A') localStorage.setItem('user', JSON.stringify({ username: 'u' })) useAuthStore().initializeAuth() expect(apiClient.defaults.headers.common.Authorization).toBe('Bearer A') }) it('does nothing when either accessToken or user is missing', () => { // Only set accessToken (no user) → initializeAuth should skip the // restore block and NOT set the axios Authorization header. localStorage.setItem('accessToken', 'A') const auth = useAuthStore() auth.initializeAuth() expect(apiClient.defaults.headers.common.Authorization).toBeUndefined() }) it('registers the response interceptor exactly once (idempotent)', () => { const auth = useAuthStore() auth.initializeAuth() auth.initializeAuth() expect(responseHandlers).toHaveLength(1) expect(requestHandlers).toHaveLength(1) }) }) // ─── login ───────────────────────────────────────────────────────── describe('login', () => { it('success: writes access/refresh/user + header + pushes /', async () => { authLogin.mockResolvedValueOnce({ access: 'A', refresh: 'R' }) const auth = useAuthStore() const out = await auth.login({ username: 'alice', password: 'pw' }) expect(out).toEqual({ success: true }) expect(auth.accessToken).toBe('A') expect(auth.refreshToken).toBe('R') expect(auth.user).toEqual({ username: 'alice' }) expect(localStorage.getItem('accessToken')).toBe('A') expect(localStorage.getItem('refreshToken')).toBe('R') expect(apiClient.defaults.headers.common.Authorization).toBe('Bearer A') expect(routerPush).toHaveBeenCalledWith('/') }) it('success with no refresh field: refreshToken stays null, not in localStorage', async () => { authLogin.mockResolvedValueOnce({ access: 'A' }) const auth = useAuthStore() await auth.login({ username: 'u', password: 'p' }) expect(auth.refreshToken).toBeNull() expect(localStorage.getItem('refreshToken')).toBeNull() }) it('returns { success: false, error } when response has no access field', async () => { authLogin.mockResolvedValueOnce({ refresh: 'R' }) // no access const out = await useAuthStore().login({ username: 'u', password: 'p' }) expect(out).toEqual({ success: false, error: '未获取到访问令牌' }) expect(routerPush).not.toHaveBeenCalled() }) it('returns { success: false, error: detail } on 401', async () => { authLogin.mockRejectedValueOnce( Object.assign(new Error('e'), { response: { data: { detail: 'Invalid creds' } }, }), ) const out = await useAuthStore().login({ username: 'u', password: 'bad' }) expect(out).toEqual({ success: false, error: 'Invalid creds' }) expect(routerPush).not.toHaveBeenCalled() }) it('falls back to error.message when no response detail is present', async () => { authLogin.mockRejectedValueOnce(new Error('boom')) const out = await useAuthStore().login({ username: 'u', password: 'p' }) expect(out).toEqual({ success: false, error: 'boom' }) }) }) // ─── logout ──────────────────────────────────────────────────────── describe('logout', () => { it('clears state, localStorage, axios header, and pushes /login', () => { const auth = useAuthStore() auth.accessToken = 'A' auth.refreshToken = 'R' auth.user = { username: 'u' } apiClient.defaults.headers.common.Authorization = 'Bearer A' auth.logout() expect(auth.accessToken).toBeNull() expect(auth.refreshToken).toBeNull() expect(auth.user).toBeNull() expect(localStorage.getItem('accessToken')).toBeNull() expect(localStorage.getItem('refreshToken')).toBeNull() expect(localStorage.getItem('user')).toBeNull() expect(apiClient.defaults.headers.common.Authorization).toBeUndefined() expect(routerPush).toHaveBeenCalledWith('/login') }) it('is safe to call when nothing is set', () => { expect(() => useAuthStore().logout()).not.toThrow() expect(routerPush).toHaveBeenCalledWith('/login') }) }) // ─── refreshAccessToken ──────────────────────────────────────────── describe('refreshAccessToken', () => { it('throws NO_REFRESH_TOKEN when no refresh token is set', async () => { await expect(useAuthStore().refreshAccessToken()).rejects.toThrow( 'NO_REFRESH_TOKEN', ) }) it('updates accessToken, localStorage, and axios header on success', async () => { authRefresh.mockResolvedValueOnce({ access: 'NEW' }) const auth = useAuthStore() auth.refreshToken = 'OLD' const out = await auth.refreshAccessToken() expect(out).toBe('NEW') expect(auth.accessToken).toBe('NEW') expect(localStorage.getItem('accessToken')).toBe('NEW') expect(apiClient.defaults.headers.common.Authorization).toBe('Bearer NEW') }) it('throws NO_ACCESS_FROM_REFRESH when response lacks an access field', async () => { authRefresh.mockResolvedValueOnce({ foo: 'bar' }) const auth = useAuthStore() auth.refreshToken = 'OLD' await expect(auth.refreshAccessToken()).rejects.toThrow( 'NO_ACCESS_FROM_REFRESH', ) }) it('dedupes concurrent calls — authRefresh invoked only once', async () => { let resolveRefresh authRefresh.mockReturnValueOnce( new Promise((r) => { resolveRefresh = r }), ) const auth = useAuthStore() auth.refreshToken = 'OLD' const p1 = auth.refreshAccessToken() const p2 = auth.refreshAccessToken() const p3 = auth.refreshAccessToken() expect(authRefresh).toHaveBeenCalledTimes(1) resolveRefresh({ access: 'NEW' }) await Promise.all([p1, p2, p3]) expect(auth.accessToken).toBe('NEW') }) }) // ─── Axios response interceptor — 401 branches ───────────────────── describe('axios response interceptor — 401 branches', () => { // Initialize the store so setupAxiosInterceptors runs and pushes the // rejected handler into responseHandlers. const init = () => useAuthStore().initializeAuth() it('[1] non-401 error: passes through, no refresh, no logout', async () => { init() const auth = useAuthStore() auth.accessToken = 'TOK' const err = makeAxiosError({ status: 500 }) await expect(triggerResponseError(err)).rejects.toBe(err) expect(authRefresh).not.toHaveBeenCalled() expect(auth.accessToken).toBe('TOK') }) it('[2] 401 on /api/token/ (login): passes through, no refresh', async () => { init() const err = makeAxiosError({ url: '/api/token/', method: 'post' }) await expect(triggerResponseError(err)).rejects.toBe(err) expect(authRefresh).not.toHaveBeenCalled() }) it('[3] 401 first time: refreshes, retries via apiClient', async () => { init() authRefresh.mockResolvedValueOnce({ access: 'NEW' }) const auth = useAuthStore() auth.refreshToken = 'OLD' const err = makeAxiosError() await triggerResponseError(err) expect(authRefresh).toHaveBeenCalledTimes(1) expect(err.config._retry).toBe(true) expect(apiClient).toHaveBeenCalledWith(err.config) }) it('[4] 401 already retried + LAN + GET: rejects without logout', async () => { init() isLanAccess.mockReturnValue(true) const auth = useAuthStore() auth.accessToken = 'TOK' localStorage.setItem('accessToken', 'TOK') const err = makeAxiosError({ method: 'get', _retry: true }) await expect(triggerResponseError(err)).rejects.toBe(err) // logout() did NOT run — token remains in state and storage expect(auth.accessToken).toBe('TOK') expect(localStorage.getItem('accessToken')).toBe('TOK') }) it('[5] 401 already retried + NOT(LAN+GET): calls logout and rejects', async () => { init() isLanAccess.mockReturnValue(false) const auth = useAuthStore() auth.accessToken = 'TOK' localStorage.setItem('accessToken', 'TOK') const err = makeAxiosError({ method: 'post', _retry: true }) await expect(triggerResponseError(err)).rejects.toBe(err) // logout() ran via interceptor — side effect: token cleared expect(auth.accessToken).toBeNull() expect(auth.user).toBeNull() expect(auth.refreshToken).toBeNull() expect(localStorage.getItem('accessToken')).toBeNull() }) it('[6] 401 first time + refresh fails + LAN + GET: rejects original error, no logout', async () => { init() isLanAccess.mockReturnValue(true) authRefresh.mockReset() authRefresh.mockRejectedValueOnce(new Error('refresh-fail')) const auth = useAuthStore() auth.refreshToken = 'OLD' auth.accessToken = 'TOK' localStorage.setItem('accessToken', 'TOK') const err = makeAxiosError({ method: 'get' }) await expect(triggerResponseError(err)).rejects.toBe(err) expect(auth.accessToken).toBe('TOK') expect(auth.user).toBeNull() // already null, no logout side effect expect(localStorage.getItem('accessToken')).toBe('TOK') }) it('[7] 401 first time + refresh fails + NOT(LAN+GET): calls logout, rejects refresh error', async () => { init() isLanAccess.mockReturnValue(false) const refreshErr = new Error('refresh-fail') authRefresh.mockReset() authRefresh.mockRejectedValueOnce(refreshErr) const auth = useAuthStore() auth.refreshToken = 'OLD' auth.accessToken = 'TOK' localStorage.setItem('accessToken', 'TOK') const err = makeAxiosError({ method: 'post' }) await expect(triggerResponseError(err)).rejects.toBe(refreshErr) expect(auth.accessToken).toBeNull() expect(auth.user).toBeNull() expect(auth.refreshToken).toBeNull() expect(localStorage.getItem('accessToken')).toBeNull() }) }) // ─── Request interceptor ─────────────────────────────────────────── describe('axios request interceptor', () => { it('attaches Authorization: Bearer when accessToken is set', () => { const auth = useAuthStore() auth.accessToken = 'TOK' auth.initializeAuth() const lastHandler = requestHandlers[requestHandlers.length - 1] const config = { headers: {} } const out = lastHandler(config) expect(out.headers.Authorization).toBe('Bearer TOK') }) it('leaves config.headers.Authorization unset when no token', () => { const auth = useAuthStore() auth.initializeAuth() const lastHandler = requestHandlers[requestHandlers.length - 1] const config = { headers: {} } const out = lastHandler(config) expect(out.headers.Authorization).toBeUndefined() }) })