mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 11:19:50 +08:00
fix(frontend): preserve session cleanup and Unicode navigation
This commit is contained in:
@@ -100,6 +100,51 @@ describe('auth store logout', () => {
|
||||
expect(clearAuthMock).toHaveBeenCalledWith(false, false)
|
||||
})
|
||||
|
||||
it.each(['synchronous', 'asynchronous'] as const)(
|
||||
'allows a forced retry after a %s restore failure',
|
||||
async (failureMode) => {
|
||||
const failure = new Error('temporary refresh failure')
|
||||
if (failureMode === 'synchronous') {
|
||||
restoreSessionMock.mockImplementationOnce(() => {
|
||||
throw failure
|
||||
})
|
||||
} else {
|
||||
restoreSessionMock.mockRejectedValueOnce(failure)
|
||||
}
|
||||
restoreSessionMock.mockResolvedValueOnce('retry-access-token')
|
||||
const store = useAuthStore()
|
||||
|
||||
await expect(store.restoreSession()).resolves.toBe(false)
|
||||
expect(clearAuthMock).toHaveBeenCalledWith(false, false)
|
||||
|
||||
await expect(store.restoreSession(true)).resolves.toBe(true)
|
||||
expect(store.token).toBe('retry-access-token')
|
||||
expect(restoreSessionMock).toHaveBeenCalledTimes(2)
|
||||
},
|
||||
)
|
||||
|
||||
it('deduplicates in-flight restores and allows a refresh after completion', async () => {
|
||||
let resolveRestore!: (token: string) => void
|
||||
const pendingRestore = new Promise<string>((resolve) => {
|
||||
resolveRestore = resolve
|
||||
})
|
||||
restoreSessionMock.mockReturnValueOnce(pendingRestore)
|
||||
const store = useAuthStore()
|
||||
|
||||
const firstRestore = store.restoreSession()
|
||||
const secondRestore = store.restoreSession()
|
||||
expect(restoreSessionMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
resolveRestore('restored-access-token')
|
||||
await expect(Promise.all([firstRestore, secondRestore])).resolves.toEqual([true, true])
|
||||
expect(store.token).toBe('restored-access-token')
|
||||
|
||||
restoreSessionMock.mockResolvedValueOnce('refreshed-access-token')
|
||||
await expect(store.restoreSession(true)).resolves.toBe(true)
|
||||
expect(store.token).toBe('refreshed-access-token')
|
||||
expect(restoreSessionMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('preserves an existing access token when a forced restore fails', async () => {
|
||||
getTokenMock.mockReturnValue('still-valid-access-token')
|
||||
restoreSessionMock.mockRejectedValue(new Error('temporary refresh conflict'))
|
||||
|
||||
@@ -81,12 +81,12 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
if (sessionRestorePromise === request) {
|
||||
sessionRestorePromise = null
|
||||
}
|
||||
}
|
||||
})()
|
||||
})().finally(() => {
|
||||
if (sessionRestorePromise === request) {
|
||||
sessionRestorePromise = null
|
||||
}
|
||||
})
|
||||
|
||||
sessionRestorePromise = request
|
||||
return request
|
||||
|
||||
@@ -6,6 +6,12 @@ import {
|
||||
safeInternalNavigationPath,
|
||||
} from '../navigationSecurity'
|
||||
|
||||
const unicodeSegments = ['👩\u200d💻', 'راهنمای\u200cکاربر', '\ue000']
|
||||
const controlCodePoints = [
|
||||
...Array.from({ length: 32 }, (_value, index) => index),
|
||||
...Array.from({ length: 33 }, (_value, index) => index + 0x7f),
|
||||
]
|
||||
|
||||
describe('safeInternalNavigationPath', () => {
|
||||
it('keeps normalized same-origin paths, queries, and fragments', () => {
|
||||
expect(safeInternalNavigationPath('/dashboard/../dashboard/api-keys?tab=active#key-1')).toBe(
|
||||
@@ -13,6 +19,17 @@ describe('safeInternalNavigationPath', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(unicodeSegments)('keeps valid Unicode query values: %s', (segment) => {
|
||||
expect(safeInternalNavigationPath(`/dashboard?search=${segment}`)).toBe(
|
||||
`/dashboard?search=${encodeURIComponent(segment)}`,
|
||||
)
|
||||
})
|
||||
|
||||
it.each(controlCodePoints)('rejects control character code point %i', (codePoint) => {
|
||||
expect(safeInternalNavigationPath(`/dashboard/before${String.fromCodePoint(codePoint)}after`))
|
||||
.toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://attacker.example/steal',
|
||||
'//attacker.example/steal',
|
||||
@@ -32,6 +49,17 @@ describe('safeExternalHttpsUrl', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it.each(unicodeSegments)('allows valid Unicode URL paths: %s', (segment) => {
|
||||
expect(safeExternalHttpsUrl(`https://provider.example/docs/${segment}`)).toBe(
|
||||
`https://provider.example/docs/${encodeURIComponent(segment)}`,
|
||||
)
|
||||
})
|
||||
|
||||
it.each(controlCodePoints)('rejects control character code point %i', (codePoint) => {
|
||||
expect(safeExternalHttpsUrl(`https://provider.example/before${String.fromCodePoint(codePoint)}after`))
|
||||
.toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'javascript:alert(1)',
|
||||
'data:text/html,attack',
|
||||
@@ -53,6 +81,17 @@ describe('safeExternalWebUrl', () => {
|
||||
expect(safeExternalWebUrl(value)).toBe(expected)
|
||||
})
|
||||
|
||||
it.each(unicodeSegments)('allows valid Unicode provider websites: %s', (segment) => {
|
||||
expect(safeExternalWebUrl(`https://provider.example/docs/${segment}`)).toBe(
|
||||
`https://provider.example/docs/${encodeURIComponent(segment)}`,
|
||||
)
|
||||
})
|
||||
|
||||
it.each(controlCodePoints)('rejects control character code point %i', (codePoint) => {
|
||||
expect(safeExternalWebUrl(`https://provider.example/before${String.fromCodePoint(codePoint)}after`))
|
||||
.toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'javascript:alert(1)',
|
||||
'data:text/html,attack',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const INTERNAL_NAVIGATION_BASE = 'https://aether.invalid'
|
||||
const UNSAFE_EXTERNAL_URL_CHARACTERS = /[\\\p{C}]/u
|
||||
const UNSAFE_EXTERNAL_URL_CHARACTERS = /[\\\p{Cc}]/u
|
||||
|
||||
function safeAbsoluteExternalUrl(
|
||||
value: string | null | undefined,
|
||||
@@ -38,7 +38,7 @@ export function safeInternalNavigationPath(value: string | null | undefined): st
|
||||
!candidate.startsWith('/')
|
||||
|| candidate.startsWith('//')
|
||||
|| candidate.includes('\\')
|
||||
|| /\p{C}/u.test(candidate)
|
||||
|| /\p{Cc}/u.test(candidate)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user