修复前端登录权限边界问题

This commit is contained in:
andy
2026-07-10 09:35:47 +08:00
parent e49b2ccc1d
commit 25efcccf72
11 changed files with 282 additions and 15 deletions

View File

@@ -1,6 +1,7 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiError } from '@/services/httpClient'
import { useAuthStore } from '@/stores/authStore'
vi.mock('@/services/authService', async (importOriginal) => {
@@ -96,8 +97,13 @@ describe('authStore', () => {
expect(store.selectedHotelId).toBe('HOTEL-TEST')
})
it('clears token and user context when restore fails', async () => {
vi.mocked(authService.fetchCurrentAuth).mockRejectedValue(new Error('AUTH_SESSION_INVALID'))
it('clears token and user context when the auth session is invalid', async () => {
vi.mocked(authService.fetchCurrentAuth).mockRejectedValue(
new ApiError('登录已失效。', 401, {
error_code: 'AUTH_SESSION_INVALID',
message: '登录已失效。',
}),
)
sessionStorage.setItem('th_hotel_access_token', 'expired-token')
const store = useAuthStore()
@@ -109,6 +115,25 @@ describe('authStore', () => {
expect(store.permissions).toEqual([])
})
it('keeps the session token when auth restore fails for a transient backend error', async () => {
vi.mocked(authService.fetchCurrentAuth).mockRejectedValue(
new ApiError('Server error', 500, {
error_code: 'AUTH_TEMPORARY_UNAVAILABLE',
message: '认证服务暂不可用。',
}),
)
sessionStorage.setItem('th_hotel_access_token', 'session-token')
const store = useAuthStore()
const restored = await store.restoreFromSession()
expect(restored).toBe(false)
expect(sessionStorage.getItem('th_hotel_access_token')).toBe('session-token')
expect(store.accessToken).toBe('session-token')
expect(store.user).toBeNull()
expect(store.restoreAttempted).toBe(false)
})
it('clears local auth context even when backend logout fails', async () => {
vi.mocked(authService.loginAuth).mockResolvedValue(createAuthPayload())
vi.mocked(authService.logoutAuth).mockRejectedValue(new Error('network down'))

View File

@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { DebugEmlUploadError, uploadDebugEmlSuperAgentRun } from '@/services/debugEmlService'
import { setUnauthorizedHandler } from '@/services/httpClient'
const jsonHeaders = {
headers: {
@@ -21,6 +22,7 @@ function mockJsonResponse(payload: unknown, status = 201): Response {
describe('debugEmlService', () => {
beforeEach(() => {
vi.restoreAllMocks()
setUnauthorizedHandler(null)
})
it('uploads an eml file with FormData and debug key header', async () => {
@@ -76,6 +78,8 @@ describe('debugEmlService', () => {
})
it('throws a typed safe error for backend error_code responses', async () => {
const unauthorizedHandler = vi.fn()
setUnauthorizedHandler(unauthorizedHandler)
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
mockJsonResponse(
{
@@ -98,5 +102,34 @@ describe('debugEmlService', () => {
errorCode: 'DEBUG_UPLOAD_KEY_INVALID',
message: 'Debug 上传访问口令缺失或错误。',
} satisfies Partial<DebugEmlUploadError>)
expect(unauthorizedHandler).not.toHaveBeenCalled()
})
it('runs the shared unauthorized handler for auth session errors', async () => {
const unauthorizedHandler = vi.fn()
setUnauthorizedHandler(unauthorizedHandler)
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
mockJsonResponse(
{
error_code: 'AUTH_SESSION_INVALID',
message: '登录已失效。',
},
401,
),
)
const file = new File(['From: guest@example.test'], 'booking.eml', { type: 'message/rfc822' })
await expect(
uploadDebugEmlSuperAgentRun({
hotelId: 'HOTEL-TEST',
debugUploadKey: 'manual-debug-key',
file,
}),
).rejects.toMatchObject({
status: 401,
errorCode: 'AUTH_SESSION_INVALID',
} satisfies Partial<DebugEmlUploadError>)
expect(unauthorizedHandler).toHaveBeenCalledOnce()
})
})

View File

@@ -85,4 +85,32 @@ describe('reservation router', () => {
name: 'unauthorized',
})
})
it('redirects the root route to the restored first backend menu', async () => {
let restored = false
let authenticated = false
let firstMenuPath: string | null = null
const authStore = {
restoreAttempted: false,
get isAuthenticated() {
return authenticated
},
get firstMenuPath() {
return firstMenuPath
},
hasStoredToken: () => true,
restoreFromSession: async () => {
restored = true
authStore.restoreAttempted = true
authenticated = true
firstMenuPath = '/reservation/tasks'
return true
},
hasPermission: () => true,
} as unknown as AuthStoreForGuard
const guard = createAuthGuard(() => authStore)
await expect(guard(createRoute('/'))).resolves.toBe('/reservation/tasks')
expect(restored).toBe(true)
})
})

View File

@@ -1,4 +1,5 @@
import { flushPromises, mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import { createMemoryHistory, createRouter } from 'vue-router'
@@ -8,6 +9,7 @@ import ReservationOrderDetailView from '@/views/reservation/ReservationOrderDeta
import ReservationOrderListView from '@/views/reservation/ReservationOrderListView.vue'
import ReservationSourceMessageConversationView from '@/views/reservation/ReservationSourceMessageConversationView.vue'
import ReservationTaskListView from '@/views/reservation/ReservationTaskListView.vue'
import { useAuthStore } from '@/stores/authStore'
import type { SourceMessageConversationResult } from '@/types/reservation'
vi.mock('@/services/reservationService', async (importOriginal) => {
@@ -91,18 +93,18 @@ function createTaskListResult(
}
}
function createOrderDetailResult() {
function createOrderDetailResult(displayName = 'GRP-001') {
return {
order: {
order_id: '20001',
order_id: displayName === 'GRP-002' ? '20002' : '20001',
hotel_id: 'HOTEL-TEST',
order_status: 'ACTIVE',
temporary_order_no: null,
confirmation_number: null,
group_code: 'GRP-001',
group_code: displayName,
block_code: null,
allotment_code: null,
display_name: 'GRP-001',
display_name: displayName,
created_at: '2026-07-08T03:00:00Z',
updated_at: '2026-07-08T03:10:00Z',
},
@@ -238,7 +240,42 @@ function createDeferred<T>() {
return { promise, resolve, reject }
}
function createAuthPayload() {
return {
access_token: 'session-token',
token_type: 'Bearer',
expires_at: '2026-07-09T12:00:00Z',
user: {
id: '1900000000000000001',
username: 'admin',
display_name: '系统管理员',
super_admin: true,
},
default_hotel_id: 'HOTEL-TEST',
hotels: [
{
hotel_id: 'HOTEL-TEST',
hotel_name: '测试酒店',
time_zone: 'Asia/Bangkok',
default_hotel: true,
},
{
hotel_id: 'HOTEL-BKK',
hotel_name: '曼谷酒店',
time_zone: 'Asia/Bangkok',
default_hotel: false,
},
],
permissions: ['HOTEL_SWITCH'],
menus: [],
}
}
async function mountWithPlugins(component: object, initialPath = '/', stubs: Record<string, unknown> = {}) {
const pinia = createPinia()
setActivePinia(pinia)
const authStore = useAuthStore()
authStore.applyLoginResult(createAuthPayload())
const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
@@ -260,7 +297,7 @@ async function mountWithPlugins(component: object, initialPath = '/', stubs: Rec
return mount(component, {
global: {
plugins: [i18n, router],
plugins: [pinia, i18n, router],
stubs: {
RouterLink: {
template: '<a><slot /></a>',
@@ -273,6 +310,8 @@ async function mountWithPlugins(component: object, initialPath = '/', stubs: Rec
describe('reservation P0 views', () => {
beforeEach(() => {
sessionStorage.clear()
localStorage.clear()
vi.mocked(service.fetchReservationOrderDetail).mockReset()
vi.mocked(service.fetchReservationOrders).mockReset()
vi.mocked(service.fetchReservationTaskList).mockReset()
@@ -366,6 +405,27 @@ describe('reservation P0 views', () => {
expect(wrapper.text()).toContain('第 3 / 4 页')
})
it('reloads the order list when the selected hotel changes', async () => {
vi.mocked(service.fetchReservationOrders)
.mockResolvedValueOnce(createOrderListResult('GRP-001'))
.mockResolvedValueOnce(createOrderListResult('GRP-002'))
const wrapper = await mountWithPlugins(ReservationOrderListView)
await vi.dynamicImportSettled()
const authStore = useAuthStore()
authStore.setSelectedHotelId('HOTEL-BKK')
await vi.dynamicImportSettled()
expect(service.fetchReservationOrders).toHaveBeenCalledTimes(2)
expect(service.fetchReservationOrders).toHaveBeenLastCalledWith(
expect.objectContaining({
page_num: 1,
}),
)
expect(wrapper.text()).toContain('GRP-002')
})
it('renders task list items returned by the backend', async () => {
vi.mocked(service.fetchReservationTaskList).mockResolvedValue(createTaskListResult('GRP-001', 'Booking Request'))
@@ -444,6 +504,48 @@ describe('reservation P0 views', () => {
expect(wrapper.text()).toContain('第 2 / 3 页')
})
it('reloads the task list when the selected hotel changes', async () => {
vi.mocked(service.fetchReservationTaskList)
.mockResolvedValueOnce(createTaskListResult('GRP-001', 'Booking Request'))
.mockResolvedValueOnce(createTaskListResult('GRP-002', 'Booking Update'))
const wrapper = await mountWithPlugins(ReservationTaskListView)
await vi.dynamicImportSettled()
const authStore = useAuthStore()
authStore.setSelectedHotelId('HOTEL-BKK')
await vi.dynamicImportSettled()
expect(service.fetchReservationTaskList).toHaveBeenCalledTimes(2)
expect(service.fetchReservationTaskList).toHaveBeenLastCalledWith(
expect.objectContaining({
page_num: 1,
}),
)
expect(wrapper.text()).toContain('Booking Update')
})
it('reloads order detail when the selected hotel changes', async () => {
vi.mocked(service.fetchReservationOrderDetail)
.mockResolvedValueOnce(createOrderDetailResult('GRP-001'))
.mockResolvedValueOnce(createOrderDetailResult('GRP-002'))
const wrapper = await mountWithPlugins(ReservationOrderDetailView, '/reservation/orders/20001', {
ReservationTaskDetailPanel: {
template: '<section />',
},
})
await vi.dynamicImportSettled()
const authStore = useAuthStore()
authStore.setSelectedHotelId('HOTEL-BKK')
await vi.dynamicImportSettled()
expect(service.fetchReservationOrderDetail).toHaveBeenCalledTimes(2)
expect(service.fetchReservationOrderDetail).toHaveBeenLastCalledWith('20001')
expect(wrapper.text()).toContain('GRP-002')
})
it('keeps the latest task list response when filters change quickly', async () => {
const firstRequest = createDeferred<Awaited<ReturnType<typeof service.fetchReservationTaskList>>>()
const secondRequest = createDeferred<Awaited<ReturnType<typeof service.fetchReservationTaskList>>>()