import { createPinia, setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ post: vi.fn(), listCurrentPlatformPermissionCodes: vi.fn(), refreshHandler: null as null | (() => Promise), })) vi.mock('@/shared/api/http', () => ({ http: { post: mocks.post }, registerAuthRefreshHandler: (handler: () => Promise) => { mocks.refreshHandler = handler }, unwrapApiResponse: (response: { code: string; message: string; data: T }) => { if (response.code !== '200') throw new Error(response.message) return response.data }, getApiErrorDetails: (error: { code?: string; message?: string }) => ({ message: error.message || '接口请求失败', code: error.code, }), })) vi.mock('./platformAuthorization', () => ({ listCurrentPlatformPermissionCodes: mocks.listCurrentPlatformPermissionCodes, })) import { useAuthStore } from './auth' function createMemoryStorage(): Storage { const values = new Map() return { get length() { return values.size }, clear: () => values.clear(), getItem: (key) => values.get(key) ?? null, key: (index) => [...values.keys()][index] ?? null, removeItem: (key) => values.delete(key), setItem: (key, value) => values.set(key, value), } } function token(overrides: Record = {}) { const claims = { user_id: '1000001', username: 'platform_admin', app_code: 'PLATFORM', exp: Math.floor(Date.now() / 1000) + 1800, ...overrides, } const payload = Buffer.from(JSON.stringify(claims)).toString('base64url') return `header.${payload}.signature` } function loginResponse(accessToken = token(), refreshToken = 'refresh-next') { return { code: '200', message: 'success', data: { tokenType: 'Bearer', accessToken, expiresIn: 1800, refreshToken, refreshExpiresIn: 604800, }, } } function unifiedLoginResponse() { return { code: '200', message: 'success', data: { tokenType: 'Bearer', accessToken: token(), expiresIn: 1800, refreshToken: 'refresh-domestic', refreshExpiresIn: 604800, }, } } describe('admin auth session', () => { beforeEach(() => { vi.stubGlobal('localStorage', createMemoryStorage()) setActivePinia(createPinia()) mocks.post.mockReset() mocks.listCurrentPlatformPermissionCodes.mockReset() mocks.refreshHandler = null }) afterEach(() => vi.unstubAllGlobals()) it('accepts the unified access and refresh token response', async () => { mocks.post.mockResolvedValue(unifiedLoginResponse()) mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list']) const auth = useAuthStore() await auth.login('platform_admin', 'password') expect(mocks.post).toHaveBeenCalledWith('/auth/login', { loginId: 'platform_admin', password: 'password', }) expect(auth.isAuthenticated).toBe(true) expect(auth.refreshToken).toBe('refresh-domestic') }) it('accepts serialized long lifetimes from the real authentication response', async () => { mocks.post.mockResolvedValue({ code: '200', message: 'success', data: { tokenType: 'Bearer', accessToken: token(), expiresIn: '7200', refreshToken: 'refresh-serialized', refreshExpiresIn: '604800', }, }) mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list']) const auth = useAuthStore() await auth.login('platform_admin', 'password') expect(auth.isAuthenticated).toBe(true) expect(auth.refreshToken).toBe('refresh-serialized') expect([...auth.permissionCodes]).toEqual(['platform:warehouse:list']) }) it('loads permissions from the current authorization endpoint when the JWT only contains identity', async () => { mocks.post.mockResolvedValue(loginResponse()) mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list']) const auth = useAuthStore() await auth.login('warehouse_operator', 'password') expect(auth.isAuthenticated).toBe(true) expect([...auth.permissionCodes]).toEqual(['platform:warehouse:list']) expect(auth.permissionResources).toEqual([]) expect(localStorage.getItem('cc-admin-permission-codes')) .toBe('["platform:warehouse:list"]') }) it('rejects login and clears the session when the current permission snapshot is unavailable', async () => { mocks.post.mockResolvedValue(loginResponse()) mocks.listCurrentPlatformPermissionCodes.mockRejectedValue({ code: 'S0503', message: 'IAM 服务不可用' }) const auth = useAuthStore() await expect(auth.login('platform_admin', 'password')).rejects.toMatchObject({ code: 'S0503' }) expect(auth.isAuthenticated).toBe(false) expect(localStorage.getItem('cc-admin-token')).toBeNull() expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull() }) it('revokes an issued token pair when the permission snapshot prevents login completion', async () => { mocks.post .mockResolvedValueOnce(loginResponse()) .mockResolvedValueOnce({ code: '200', message: 'success', data: null }) mocks.listCurrentPlatformPermissionCodes.mockRejectedValue({ code: 'S0503', message: 'IAM 服务不可用' }) const auth = useAuthStore() await expect(auth.login('platform_admin', 'password')).rejects.toMatchObject({ code: 'S0503' }) await Promise.resolve() expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/logout', { refreshToken: 'refresh-next' }, { headers: { Authorization: expect.stringContaining('Bearer header.') }, }) }) it('restores an expired access session with the persisted refresh token', async () => { localStorage.setItem('cc-admin-token', token({ exp: Math.floor(Date.now() / 1000) - 30 })) localStorage.setItem('cc-admin-refresh-token', 'refresh-old') mocks.post.mockResolvedValue(loginResponse(token(), 'refresh-rotated')) mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list']) const auth = useAuthStore() await expect(auth.restoreSession()).resolves.toBe(true) expect(mocks.post).toHaveBeenCalledWith('/auth/refresh', { refreshToken: 'refresh-old' }) expect(auth.refreshToken).toBe('refresh-rotated') expect(auth.isAuthenticated).toBe(true) }) it('reuses a valid persisted session without blocking reload on refresh', async () => { localStorage.setItem('cc-admin-token', token()) localStorage.setItem('cc-admin-refresh-token', 'refresh-old') localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]') const auth = useAuthStore() await expect(auth.restoreSession()).resolves.toBe(true) expect(mocks.post).not.toHaveBeenCalled() expect([...auth.permissionCodes]).toEqual(['platform:warehouse:list']) expect(localStorage.getItem('cc-admin-permission-codes')).toBe('["platform:warehouse:list"]') }) it('reuses a session rotated by another tab without refreshing it again', async () => { const previousToken = token({ token_version: 1 }) const rotatedToken = token({ token_version: 2 }) localStorage.setItem('cc-admin-token', previousToken) localStorage.setItem('cc-admin-refresh-token', 'refresh-old') localStorage.setItem('cc-admin-permission-codes', '["platform:warehouse:list"]') vi.stubGlobal('navigator', { locks: { request: vi.fn(async (_name: string, _options: LockOptions, callback: () => Promise) => { localStorage.setItem('cc-admin-token', rotatedToken) localStorage.setItem('cc-admin-refresh-token', 'refresh-rotated') localStorage.setItem('cc-admin-permission-codes', '["platform:firstleg:inbound:list"]') return callback() }), }, }) const auth = useAuthStore() await expect(auth.refreshSession()).resolves.toBeUndefined() expect(mocks.post).not.toHaveBeenCalled() expect(auth.token).toBe(rotatedToken) expect(auth.refreshToken).toBe('refresh-rotated') expect([...auth.permissionCodes]).toEqual(['platform:firstleg:inbound:list']) }) it('does not let an in-flight refresh restore a session after logout', async () => { localStorage.setItem('cc-admin-token', token()) localStorage.setItem('cc-admin-refresh-token', 'refresh-old') let resolveRefresh: ((value: ReturnType) => void) | undefined mocks.post.mockImplementation((url: string) => { if (url === '/auth/refresh') { return new Promise((resolve) => { resolveRefresh = resolve }) } return Promise.resolve({ code: '200', message: 'success', data: null }) }) const auth = useAuthStore() const refreshing = auth.refreshSession() await Promise.resolve() await auth.logout() resolveRefresh?.(loginResponse(token(), 'refresh-rotated')) await expect(refreshing).rejects.toThrow('登录会话已被后续操作替代') expect(auth.isAuthenticated).toBe(false) expect(auth.refreshToken).toBe('') expect(localStorage.getItem('cc-admin-token')).toBeNull() }) it('refreshes a rejected logout once and revokes the rotated pair without restoring local state', async () => { const previous = token() const rotated = token({ token_version: 2 }) localStorage.setItem('cc-admin-token', previous) localStorage.setItem('cc-admin-refresh-token', 'refresh-old') mocks.post.mockRejectedValueOnce({ isAxiosError: true, response: { status: 401 } }) .mockResolvedValueOnce(loginResponse(rotated, 'refresh-rotated')) .mockResolvedValueOnce({ code: '200', data: null }) const auth = useAuthStore() await expect(auth.logout()).resolves.toBeUndefined() expect(mocks.post).toHaveBeenCalledTimes(3) expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/refresh', { refreshToken: 'refresh-old' }) expect(mocks.post).toHaveBeenNthCalledWith(3, '/auth/logout', { refreshToken: 'refresh-rotated' }, { headers: { Authorization: `Bearer ${rotated}` }, }) expect(auth.isAuthenticated).toBe(false) expect(localStorage.getItem('cc-admin-token')).toBeNull() expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull() expect(mocks.listCurrentPlatformPermissionCodes).not.toHaveBeenCalled() }) it('revokes a persisted refresh session when startup already removed its expired access token', async () => { localStorage.setItem('cc-admin-token', token({ exp: 1 })) localStorage.setItem('cc-admin-refresh-token', 'refresh-old') const rotated = token() mocks.post.mockResolvedValueOnce(loginResponse(rotated, 'refresh-rotated')) .mockResolvedValueOnce({ code: '200', data: null }) const auth = useAuthStore() expect(auth.token).toBe('') await auth.logout() expect(mocks.post).toHaveBeenCalledTimes(2) expect(mocks.post).toHaveBeenNthCalledWith(1, '/auth/refresh', { refreshToken: 'refresh-old' }) expect(mocks.post).toHaveBeenNthCalledWith(2, '/auth/logout', { refreshToken: 'refresh-rotated' }, { headers: { Authorization: `Bearer ${rotated}` }, }) expect(auth.isAuthenticated).toBe(false) }) it('leaves a subsequent login intact while finishing revocation of the previous session', async () => { localStorage.setItem('cc-admin-token', token()) localStorage.setItem('cc-admin-refresh-token', 'refresh-old') let finishRefresh: ((value: ReturnType) => void) | undefined const started = new Promise(resolve => { mocks.post.mockRejectedValueOnce({ isAxiosError: true, response: { status: 401 } }) .mockImplementationOnce(() => { resolve(); return new Promise(done => { finishRefresh = done }) }) }) const auth = useAuthStore() const loggingOut = auth.logout() // Attach immediately so a regression rejecting early does not become an unhandled rejection. const result = loggingOut.then(() => null, error => error) await Promise.race([started, loggingOut.catch(() => undefined)]) expect(finishRefresh).toBeTypeOf('function') const newAccess = token({ user_id: '1000002', username: 'second' }) mocks.post.mockResolvedValueOnce(loginResponse(newAccess, 'refresh-new-login')) .mockResolvedValueOnce({ code: '200', data: null }) mocks.listCurrentPlatformPermissionCodes.mockResolvedValue(['platform:warehouse:list']) await auth.login('second', 'password') const revocationAccess = token({ token_version: 3 }) finishRefresh?.(loginResponse(revocationAccess, 'refresh-revocation-only')) expect(await result).toBeNull() expect(auth.token).toBe(newAccess) expect(localStorage.getItem('cc-admin-refresh-token')).toBe('refresh-new-login') expect(mocks.post).toHaveBeenLastCalledWith('/auth/logout', { refreshToken: 'refresh-revocation-only' }, { headers: { Authorization: `Bearer ${revocationAccess}` }, }) }) it('does not refresh or retry an ambiguous logout transport failure', async () => { localStorage.setItem('cc-admin-token', token()) localStorage.setItem('cc-admin-refresh-token', 'refresh-old') const failure = { isAxiosError: true, response: { status: 503 } } mocks.post.mockRejectedValue(failure) const auth = useAuthStore() await expect(auth.logout()).rejects.toBe(failure) expect(mocks.post).toHaveBeenCalledTimes(1) expect(auth.isAuthenticated).toBe(false) }) it.each(['refresh', 'second-logout'])('stops after a %s rejection and never restores the logged-out session', async stage => { localStorage.setItem('cc-admin-token', token()) localStorage.setItem('cc-admin-refresh-token', 'refresh-old') const unauthorized = { isAxiosError: true, response: { status: 401 } } mocks.post.mockRejectedValueOnce(unauthorized) if (stage === 'second-logout') mocks.post.mockResolvedValueOnce(loginResponse()) mocks.post.mockRejectedValueOnce(unauthorized) const auth = useAuthStore() await expect(auth.logout()).rejects.toBe(unauthorized) expect(mocks.post).toHaveBeenCalledTimes(stage === 'refresh' ? 2 : 3) expect(localStorage.getItem('cc-admin-token')).toBeNull() expect(localStorage.getItem('cc-admin-refresh-token')).toBeNull() }) })