Files
admin-web/src/core/auth/auth.test.ts
T

342 lines
14 KiB
TypeScript

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<string>),
}))
vi.mock('@/shared/api/http', () => ({
http: { post: mocks.post },
registerAuthRefreshHandler: (handler: () => Promise<string>) => { mocks.refreshHandler = handler },
unwrapApiResponse: <T>(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<string, string>()
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<string, unknown> = {}) {
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<void>) => {
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<typeof loginResponse>) => 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<typeof loginResponse>) => void) | undefined
const started = new Promise<void>(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()
})
})