fix(auth): preserve exact account IDs when decoding JWT claims
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeJwtClaims } from './jwt'
|
||||
|
||||
function token(payload: string) {
|
||||
return `header.${Buffer.from(payload).toString('base64url')}.signature`
|
||||
}
|
||||
|
||||
describe('JWT claim decoding', () => {
|
||||
it.each(['2110664514823360515', '"2110664514823360515"'])('preserves the exact account ID encoded as %s', userId => {
|
||||
const claims = decodeJwtClaims(token(`{"user_id":${userId},"app_code":"PLATFORM","exp":1792060000,"iat":1792058200}`))
|
||||
expect(claims.user_id).toBe('2110664514823360515')
|
||||
expect(claims.exp).toBe(1792060000)
|
||||
expect(claims).toHaveProperty('iat', 1792058200)
|
||||
})
|
||||
|
||||
it('retains safe numeric IDs and ordinary claims', () => {
|
||||
expect(decodeJwtClaims(token('{"user_id":123,"username":"operator","roles":["ADMIN"],"exp":1}')))
|
||||
.toEqual({ user_id: 123, username: 'operator', roles: ['ADMIN'], exp: 1 })
|
||||
})
|
||||
|
||||
it.each(['', 'header..signature'])('rejects a missing payload: %s', value => {
|
||||
expect(() => decodeJwtClaims(value)).toThrow('登录响应中的 accessToken 格式无效')
|
||||
})
|
||||
|
||||
it.each(['header.%.signature', token('{"user_id":2110664514823360515,')])('rejects malformed encoding or JSON: %s', value => {
|
||||
expect(() => decodeJwtClaims(value)).toThrow('登录响应中的 accessToken 无法解析')
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,5 @@
|
||||
import { parseJsonWithSafeIntegers } from '@chongchong/shared'
|
||||
|
||||
export interface PlatformJwtClaims {
|
||||
user_id?: string | number
|
||||
username?: string
|
||||
@@ -19,7 +21,7 @@ export function decodeJwtClaims(token: string): PlatformJwtClaims {
|
||||
const payload = token.split('.')[1]
|
||||
if (!payload) throw new Error('登录响应中的 accessToken 格式无效')
|
||||
try {
|
||||
return JSON.parse(decodeBase64Url(payload)) as PlatformJwtClaims
|
||||
return parseJsonWithSafeIntegers(decodeBase64Url(payload)) as PlatformJwtClaims
|
||||
} catch {
|
||||
throw new Error('登录响应中的 accessToken 无法解析')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import WorkCommandForm from './WorkCommandForm.vue'
|
||||
import { submitWorkCommand } from './workApi'
|
||||
import { followAction, workDetail, workItem } from './work.test-support'
|
||||
import { useAuthStore } from '@/core/auth/auth'
|
||||
|
||||
vi.mock('@/core/auth/platformAuthorization', () => ({ listCurrentPlatformPermissionCodes: vi.fn().mockResolvedValue(['platform:appointment:followup']) }))
|
||||
vi.mock('@/core/access/accessPreview', () => ({ useAccessPreviewStore: () => ({ can: () => true }) }))
|
||||
vi.mock('element-plus', () => ({ ElMessage: { warning: vi.fn(), success: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }))
|
||||
vi.mock('./workApi', async original => ({ ...await original<typeof import('./workApi')>(), submitWorkCommand: vi.fn() }))
|
||||
|
||||
function token(userId: string, exp = Math.floor(Date.now() / 1000) + 1800) {
|
||||
// Keep the numeric JSON literal intact; constructing a JS number would lose precision before decoding.
|
||||
const payload = `{"user_id":${userId},"username":"test_operator","app_code":"PLATFORM","exp":${exp}}`
|
||||
return `header.${Buffer.from(payload).toString('base64url')}.signature`
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const values = new Map<string, string>()
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
removeItem: (key: string) => values.delete(key),
|
||||
})
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(submitWorkCommand).mockResolvedValue({ item: workItem(), affectedWorkItemKeys: [] })
|
||||
})
|
||||
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
it.each([
|
||||
['login', '2110664514823360515'], ['login', '"2110664514823360515"'],
|
||||
['restore', '2110664514823360515'], ['restore', '"2110664514823360515"'],
|
||||
])('preserves the %s session ID through the real follow-up form for JSON %s', async (mode, userId) => {
|
||||
const accessToken = token(userId)
|
||||
if (mode === 'restore') {
|
||||
localStorage.setItem('cc-admin-token', accessToken)
|
||||
localStorage.setItem('cc-admin-permission-codes', '[]')
|
||||
}
|
||||
const auth = useAuthStore()
|
||||
if (mode === 'login') {
|
||||
vi.spyOn(http, 'post').mockResolvedValue({ code: '200', data: { accessToken, refreshToken: 'test-refresh', expiresIn: 1800, refreshExpiresIn: 604800 } })
|
||||
await auth.login('test_operator', 'test-password')
|
||||
}
|
||||
expect(auth.user?.id).toBe('2110664514823360515')
|
||||
expect(auth.isAuthenticated).toBe(true)
|
||||
const wrapper = mount(WorkCommandForm, {
|
||||
props: { detail: workDetail({ item: workItem({ owner: null }) }), action: followAction, refreshing: false },
|
||||
global: {
|
||||
provide: { 'appointment-work-leave-guard': () => () => undefined },
|
||||
stubs: { ElButton: { template: '<button><slot /></button>' }, LoadErrorState: true, ApiErrorMeta: true },
|
||||
},
|
||||
})
|
||||
try {
|
||||
expect((wrapper.get('select').element as HTMLSelectElement).value).toBe('2110664514823360515')
|
||||
await wrapper.get('textarea').setValue('已联系负责人')
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(submitWorkCommand).toHaveBeenCalledWith('work-delivery-1', expect.objectContaining({
|
||||
command: 'SAVE_FOLLOWUP', payload: { ownerId: '2110664514823360515', followupDate: '2026-10-15', note: '已联系负责人' },
|
||||
}))
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
|
||||
it.each([token('2110664514823360515', 1), 'header.%.signature'])('does not restore an expired or malformed token: %s', accessToken => {
|
||||
localStorage.setItem('cc-admin-token', accessToken)
|
||||
const auth = useAuthStore()
|
||||
expect(auth.user).toBeNull()
|
||||
expect(auth.isAuthenticated).toBe(false)
|
||||
expect(localStorage.getItem('cc-admin-token')).toBeNull()
|
||||
})
|
||||
Reference in New Issue
Block a user