fix(auth): 守卫改到 navigation 统一出口,修复拦截器误改写请求 url
This commit is contained in:
parent
55c3ab8874
commit
bd71d52c7f
|
|
@ -1,13 +1,9 @@
|
|||
import { PropsWithChildren } from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { store } from './store'
|
||||
import { setupAuthInterceptors } from './utils/auth'
|
||||
|
||||
import './styles/reset.scss'
|
||||
|
||||
// 路由登录拦截需在任何导航发生前注册(模块加载时机,非组件渲染时机)
|
||||
setupAuthInterceptors()
|
||||
|
||||
function App({ children }: PropsWithChildren) {
|
||||
return <Provider store={store}>{children}</Provider>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +1,18 @@
|
|||
import Taro from '@tarojs/taro'
|
||||
import { ROUTES } from '@/constants/routes'
|
||||
import { store } from '@/store'
|
||||
import { storage } from '@/utils/storage'
|
||||
|
||||
// 路由级登录拦截(auth guard)
|
||||
// 原理:Taro.addInterceptor 洋葱拦截 navigateTo/reLaunch/redirectTo/switchTab,
|
||||
// 未登录时改写目标 url 为登录页。冷启动直达场景由各受保护页面 useDidShow 兜底。
|
||||
// 路由级登录守卫(auth guard)
|
||||
// addInterceptor 对所有 Taro API 生效(含 Taro.request,同样带 url 字段),
|
||||
// 在拦截器内改写 url 会把 http 请求地址误改成页面路径(request:fail invalid url),
|
||||
// 故不用全局拦截器,改为在 navigation 统一出口做跳转前校验。
|
||||
|
||||
// 登录页及无需登录即可访问的页面(放行名单)
|
||||
const PUBLIC_PAGES = new Set<string>([ROUTES.LOGIN])
|
||||
export const PUBLIC_PAGES = new Set<string>([ROUTES.LOGIN])
|
||||
|
||||
// 页面是否需要登录态
|
||||
function isProtectedPage(url: string): boolean {
|
||||
// 登录页路径(navigation 守卫改投目标)
|
||||
export const ROUTES_LOGIN = ROUTES.LOGIN
|
||||
|
||||
// 页面是否需要登录态(入参为页面跳转 url,非 http url)
|
||||
export function isProtectedPage(url: string): boolean {
|
||||
const page = url.split('?')[0]
|
||||
return !PUBLIC_PAGES.has(page)
|
||||
}
|
||||
|
||||
function isLoggedIn(): boolean {
|
||||
// 双读:store 内尚未恢复时(理论上仅冷启动瞬态)从 storage 兜底
|
||||
return Boolean(store.getState().auth.token ?? storage.get<string>('auth_token'))
|
||||
}
|
||||
|
||||
// 受保护页面跳转前校验登录态,未登录改投登录页
|
||||
export function authRouteInterceptor(chain: Taro.Chain): Promise<unknown> {
|
||||
const { url } = chain.requestParams
|
||||
// addInterceptor 对所有 Taro API 生效(非仅导航类):无 url 的 API(toast/login 等)直接放行
|
||||
if (typeof url === 'string' && url !== '' && isProtectedPage(url) && !isLoggedIn()) {
|
||||
return chain.proceed({ ...chain.requestParams, url: ROUTES.LOGIN })
|
||||
}
|
||||
return chain.proceed(chain.requestParams)
|
||||
}
|
||||
|
||||
export function setupAuthInterceptors(): void {
|
||||
Taro.addInterceptor(authRouteInterceptor)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,20 @@
|
|||
import Taro from '@tarojs/taro'
|
||||
import { store } from '@/store'
|
||||
import { storage } from '@/utils/storage'
|
||||
import { isProtectedPage, ROUTES_LOGIN } from './auth'
|
||||
|
||||
export const navigateTo = (url: string) => Taro.navigateTo({ url })
|
||||
// 登录态双读:store 内尚未恢复时(理论上仅冷启动瞬态)从 storage 兜底
|
||||
function isLoggedIn(): boolean {
|
||||
return Boolean(store.getState().auth.token ?? storage.get<string>('auth_token'))
|
||||
}
|
||||
|
||||
// 受保护页面跳转前校验登录态,未登录改投登录页
|
||||
function guardUrl(url: string): string {
|
||||
return isProtectedPage(url) && !isLoggedIn() ? ROUTES_LOGIN : url
|
||||
}
|
||||
|
||||
export const navigateTo = (url: string) => Taro.navigateTo({ url: guardUrl(url) })
|
||||
export const navigateBack = (delta = 1) => Taro.navigateBack({ delta })
|
||||
export const redirectTo = (url: string) => Taro.redirectTo({ url })
|
||||
export const reLaunch = (url: string) => Taro.reLaunch({ url })
|
||||
export const redirectTo = (url: string) => Taro.redirectTo({ url: guardUrl(url) })
|
||||
export const reLaunch = (url: string) => Taro.reLaunch({ url: guardUrl(url) })
|
||||
export const switchTab = (url: string) => Taro.switchTab({ url: guardUrl(url) })
|
||||
|
|
|
|||
|
|
@ -1,7 +1,19 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
// auth.ts 顶层 import Taro,需 mock(拦截器测试本身不触达 Taro API)
|
||||
vi.mock('@tarojs/taro', () => ({ default: {} }))
|
||||
// navigation → Taro 只透传最终参数,mock 记录调用
|
||||
const mockNavigateTo = vi.hoisted(() => vi.fn())
|
||||
const mockReLaunch = vi.hoisted(() => vi.fn())
|
||||
const mockRedirectTo = vi.hoisted(() => vi.fn())
|
||||
const mockSwitchTab = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@tarojs/taro', () => ({
|
||||
default: {
|
||||
navigateTo: mockNavigateTo,
|
||||
reLaunch: mockReLaunch,
|
||||
redirectTo: mockRedirectTo,
|
||||
switchTab: mockSwitchTab,
|
||||
navigateBack: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// store mock:authState 可变,支持模拟已登录/未登录两种状态
|
||||
const authState = vi.hoisted(() => ({ token: null as string | null }))
|
||||
|
|
@ -21,64 +33,61 @@ vi.mock('@/utils/storage', () => ({
|
|||
}
|
||||
}))
|
||||
|
||||
import { authRouteInterceptor } from '@/utils/auth'
|
||||
import { navigateTo, reLaunch, redirectTo, switchTab } from '@/utils/navigation'
|
||||
import { isProtectedPage } from '@/utils/auth'
|
||||
import { ROUTES } from '@/constants/routes'
|
||||
|
||||
// 构造拦截器链:记录 proceed 收到的参数,返回受控 promise
|
||||
function makeChain(url?: string) {
|
||||
const received: unknown[] = []
|
||||
const chain = {
|
||||
requestParams: url === undefined ? {} : { url },
|
||||
proceed(params: unknown) {
|
||||
received.push(params)
|
||||
return Promise.resolve('proceeded')
|
||||
}
|
||||
}
|
||||
return { chain, received }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authState.token = null
|
||||
mem.clear()
|
||||
mockNavigateTo.mockClear()
|
||||
mockReLaunch.mockClear()
|
||||
mockRedirectTo.mockClear()
|
||||
mockSwitchTab.mockClear()
|
||||
})
|
||||
|
||||
describe('authRouteInterceptor', () => {
|
||||
it('未登录访问受保护页:url 被改写为登录页', async () => {
|
||||
const { chain, received } = makeChain(ROUTES.HOME)
|
||||
await authRouteInterceptor(chain as never)
|
||||
expect(received[0]).toMatchObject({ url: ROUTES.LOGIN })
|
||||
describe('navigation 登录守卫', () => {
|
||||
it('未登录 navigateTo 受保护页:url 被改写为登录页', () => {
|
||||
navigateTo(ROUTES.FAMILY)
|
||||
expect(mockNavigateTo).toHaveBeenCalledWith({ url: ROUTES.LOGIN })
|
||||
})
|
||||
|
||||
it('已登录访问受保护页:原样放行', async () => {
|
||||
it('未登录 reLaunch/redirectTo/switchTab 同样被守卫', () => {
|
||||
reLaunch(ROUTES.HOME)
|
||||
redirectTo(ROUTES.DEVICE_ADD)
|
||||
switchTab(ROUTES.EVENT)
|
||||
expect(mockReLaunch).toHaveBeenCalledWith({ url: ROUTES.LOGIN })
|
||||
expect(mockRedirectTo).toHaveBeenCalledWith({ url: ROUTES.LOGIN })
|
||||
expect(mockSwitchTab).toHaveBeenCalledWith({ url: ROUTES.LOGIN })
|
||||
})
|
||||
|
||||
it('已登录 navigateTo:原样放行', () => {
|
||||
authState.token = 'tk-1'
|
||||
const { chain, received } = makeChain(ROUTES.DEVICE_ADD)
|
||||
await authRouteInterceptor(chain as never)
|
||||
expect(received[0]).toMatchObject({ url: ROUTES.DEVICE_ADD })
|
||||
navigateTo(ROUTES.FAMILY)
|
||||
expect(mockNavigateTo).toHaveBeenCalledWith({ url: ROUTES.FAMILY })
|
||||
})
|
||||
|
||||
it('未登录访问登录页:放行(登录页在公开名单)', async () => {
|
||||
const { chain, received } = makeChain(ROUTES.LOGIN)
|
||||
await authRouteInterceptor(chain as never)
|
||||
expect(received[0]).toMatchObject({ url: ROUTES.LOGIN })
|
||||
it('未登录访问登录页:放行(登录页在公开名单)', () => {
|
||||
navigateTo(ROUTES.LOGIN)
|
||||
expect(mockNavigateTo).toHaveBeenCalledWith({ url: ROUTES.LOGIN })
|
||||
})
|
||||
|
||||
it('带 query 的受保护页:仅取路径判断,跳登录页不带残留参数', async () => {
|
||||
const { chain, received } = makeChain(`${ROUTES.FAMILY_DETAIL}?id=3`)
|
||||
await authRouteInterceptor(chain as never)
|
||||
expect(received[0]).toMatchObject({ url: ROUTES.LOGIN })
|
||||
it('带 query 的受保护页:跳登录页不带残留参数', () => {
|
||||
navigateTo(`${ROUTES.FAMILY_DETAIL}?id=3`)
|
||||
expect(mockNavigateTo).toHaveBeenCalledWith({ url: ROUTES.LOGIN })
|
||||
})
|
||||
|
||||
it('store 无 token 但 storage 有(冷启动兜底):放行', async () => {
|
||||
it('store 无 token 但 storage 有(冷启动兜底):放行', () => {
|
||||
mem.set('auth_token', 'tk-saved')
|
||||
const { chain, received } = makeChain(ROUTES.HOME)
|
||||
await authRouteInterceptor(chain as never)
|
||||
expect(received[0]).toMatchObject({ url: ROUTES.HOME })
|
||||
navigateTo(ROUTES.HOME)
|
||||
expect(mockNavigateTo).toHaveBeenCalledWith({ url: ROUTES.HOME })
|
||||
})
|
||||
})
|
||||
|
||||
it('无 url 的 API 调用(toast/login 等,拦截器全局生效):原样放行', async () => {
|
||||
const { chain, received } = makeChain(undefined)
|
||||
await authRouteInterceptor(chain as never)
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0]).toEqual({})
|
||||
describe('isProtectedPage', () => {
|
||||
it('登录页返回 false,其余页面返回 true', () => {
|
||||
expect(isProtectedPage(ROUTES.LOGIN)).toBe(false)
|
||||
expect(isProtectedPage(ROUTES.HOME)).toBe(true)
|
||||
expect(isProtectedPage(`${ROUTES.LOGIN}?from=xxx`)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue