diff --git a/src/core/auth/jwt.test.ts b/src/core/auth/jwt.test.ts new file mode 100644 index 00000000..f8869537 --- /dev/null +++ b/src/core/auth/jwt.test.ts @@ -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 无法解析') + }) +}) diff --git a/src/core/auth/jwt.ts b/src/core/auth/jwt.ts index 87465ebf..f104d1e0 100644 --- a/src/core/auth/jwt.ts +++ b/src/core/auth/jwt.ts @@ -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 无法解析') } diff --git a/src/modules/appointment-calendar/WorkCommandForm.auth.test.ts b/src/modules/appointment-calendar/WorkCommandForm.auth.test.ts new file mode 100644 index 00000000..42705ca7 --- /dev/null +++ b/src/modules/appointment-calendar/WorkCommandForm.auth.test.ts @@ -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(), 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() + 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: '' }, 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() +})