178 lines
6.8 KiB
TypeScript
178 lines
6.8 KiB
TypeScript
import { createPinia, setActivePinia } from 'pinia'
|
|
import { 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,
|
|
},
|
|
}
|
|
}
|
|
|
|
describe('admin auth session', () => {
|
|
beforeEach(() => {
|
|
vi.stubGlobal('localStorage', createMemoryStorage())
|
|
setActivePinia(createPinia())
|
|
mocks.post.mockReset()
|
|
mocks.listCurrentPlatformPermissionCodes.mockReset()
|
|
mocks.refreshHandler = null
|
|
})
|
|
|
|
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('refreshes a valid persisted session so newly granted permissions appear after reload', 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 refreshedToken = token({
|
|
token_version: 2,
|
|
})
|
|
mocks.post.mockResolvedValue(loginResponse(refreshedToken, 'refresh-rotated'))
|
|
mocks.listCurrentPlatformPermissionCodes.mockResolvedValue([
|
|
'platform:warehouse:list',
|
|
'platform:firstleg:inbound:list',
|
|
])
|
|
const auth = useAuthStore()
|
|
|
|
await expect(auth.restoreSession()).resolves.toBe(true)
|
|
|
|
expect(mocks.post).toHaveBeenCalledWith('/auth/refresh', { refreshToken: 'refresh-old' })
|
|
expect([...auth.permissionCodes]).toEqual([
|
|
'platform:warehouse:list',
|
|
'platform:firstleg:inbound:list',
|
|
])
|
|
expect(localStorage.getItem('cc-admin-permission-codes'))
|
|
.toBe('["platform:warehouse:list","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()
|
|
})
|
|
})
|