优化构架
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
decideRouteAccess,
|
||||
resolvePreviewTarget,
|
||||
sanitizeInternalPath,
|
||||
} from './accessDecision'
|
||||
|
||||
describe('sanitizeInternalPath', () => {
|
||||
it.each([
|
||||
[undefined, '/'],
|
||||
[null, '/'],
|
||||
[42, '/'],
|
||||
['', '/'],
|
||||
['customers', '/'],
|
||||
['//example.com', '/'],
|
||||
['/\\example.com', '/'],
|
||||
['/\n/example.com', '/'],
|
||||
['/\r/example.com', '/'],
|
||||
['/\t/example.com', '/'],
|
||||
['/\u0000/example.com', '/'],
|
||||
['/\u007f/example.com', '/'],
|
||||
['https://example.com', '/'],
|
||||
])('sanitizes %s to %s', (value, expected) => {
|
||||
expect(sanitizeInternalPath(value)).toBe(expected)
|
||||
})
|
||||
|
||||
it('accepts an internal path with its query string', () => {
|
||||
expect(sanitizeInternalPath('/customers?status=pending')).toBe('/customers?status=pending')
|
||||
})
|
||||
})
|
||||
|
||||
describe('decideRouteAccess', () => {
|
||||
it('checks authentication before route permission', () => {
|
||||
expect(decideRouteAccess({
|
||||
requiresAuth: true,
|
||||
isAuthenticated: false,
|
||||
fullPath: '/system/roles?tab=enabled',
|
||||
permission: 'system:role:list',
|
||||
hasPermission: false,
|
||||
})).toEqual({ type: 'login', redirect: '/system/roles?tab=enabled' })
|
||||
})
|
||||
|
||||
it('sanitizes an unsafe login redirect', () => {
|
||||
expect(decideRouteAccess({
|
||||
requiresAuth: true,
|
||||
isAuthenticated: false,
|
||||
fullPath: '//example.com',
|
||||
permission: 'system:role:list',
|
||||
hasPermission: false,
|
||||
})).toEqual({ type: 'login', redirect: '/' })
|
||||
})
|
||||
|
||||
it('sends an authenticated user without permission to forbidden and preserves the internal source', () => {
|
||||
expect(decideRouteAccess({
|
||||
requiresAuth: true,
|
||||
isAuthenticated: true,
|
||||
fullPath: '/customers?status=pending',
|
||||
permission: 'customer:list',
|
||||
hasPermission: false,
|
||||
})).toEqual({ type: 'forbidden', from: '/customers?status=pending' })
|
||||
})
|
||||
|
||||
it('allows the authenticated forbidden route because it has no permission metadata', () => {
|
||||
expect(decideRouteAccess({
|
||||
requiresAuth: true,
|
||||
isAuthenticated: true,
|
||||
fullPath: '/403?from=%2Fcustomers',
|
||||
hasPermission: false,
|
||||
})).toEqual({ type: 'allow' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePreviewTarget', () => {
|
||||
it('recovers from forbidden to the first accessible route', () => {
|
||||
expect(resolvePreviewTarget({
|
||||
currentPath: '/403',
|
||||
currentPermission: undefined,
|
||||
candidateCodes: new Set(['customer:list']),
|
||||
firstPath: '/customers',
|
||||
})).toBe('/customers')
|
||||
})
|
||||
|
||||
it('stays on forbidden when the candidate has no accessible route', () => {
|
||||
expect(resolvePreviewTarget({
|
||||
currentPath: '/403',
|
||||
currentPermission: undefined,
|
||||
candidateCodes: new Set<string>(),
|
||||
firstPath: null,
|
||||
})).toBe('/403')
|
||||
})
|
||||
|
||||
it('falls back when the current route permission is lost', () => {
|
||||
expect(resolvePreviewTarget({
|
||||
currentPath: '/system/roles',
|
||||
currentPermission: 'system:role:list',
|
||||
candidateCodes: new Set(['customer:list']),
|
||||
firstPath: '/customers',
|
||||
})).toBe('/customers')
|
||||
})
|
||||
|
||||
it('keeps the current route when its permission remains available', () => {
|
||||
expect(resolvePreviewTarget({
|
||||
currentPath: '/customers',
|
||||
currentPermission: 'customer:list',
|
||||
candidateCodes: new Set(['customer:list']),
|
||||
firstPath: '/customers',
|
||||
})).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
export type RouteAccessDecision =
|
||||
| { type: 'allow' }
|
||||
| { type: 'login'; redirect: string }
|
||||
| { type: 'forbidden'; from: string }
|
||||
|
||||
export interface RouteAccessInput {
|
||||
requiresAuth?: boolean
|
||||
isAuthenticated: boolean
|
||||
fullPath: string
|
||||
permission?: string
|
||||
hasPermission: boolean
|
||||
}
|
||||
|
||||
export interface PreviewTargetInput {
|
||||
currentPath: string
|
||||
currentPermission?: string
|
||||
candidateCodes: ReadonlySet<string>
|
||||
firstPath: string | null
|
||||
}
|
||||
|
||||
export function sanitizeInternalPath(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string'
|
||||
|| !value.startsWith('/')
|
||||
|| value[1] === '/'
|
||||
|| value[1] === '\\'
|
||||
|| /[\u0000-\u001F\u007F]/.test(value)
|
||||
) return '/'
|
||||
return value
|
||||
}
|
||||
|
||||
export function decideRouteAccess(input: RouteAccessInput): RouteAccessDecision {
|
||||
if (input.requiresAuth && !input.isAuthenticated) {
|
||||
return { type: 'login', redirect: sanitizeInternalPath(input.fullPath) }
|
||||
}
|
||||
if (input.permission && !input.hasPermission) {
|
||||
return { type: 'forbidden', from: sanitizeInternalPath(input.fullPath) }
|
||||
}
|
||||
return { type: 'allow' }
|
||||
}
|
||||
|
||||
export function resolvePreviewTarget(input: PreviewTargetInput): string | null {
|
||||
if (input.currentPath === '/403') return input.firstPath ?? '/403'
|
||||
if (input.currentPermission && !input.candidateCodes.has(input.currentPermission)) {
|
||||
return input.firstPath ?? '/403'
|
||||
}
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user