mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-11 11:49:50 +08:00
Merge pull request #804 from AAEE86/fix-frontend-eslint
Fix frontend ESLint issues
This commit is contained in:
@@ -392,8 +392,7 @@ class ApiClient {
|
|||||||
|
|
||||||
this.isRefreshing = true
|
this.isRefreshing = true
|
||||||
const requestAuthStateVersion = this.authStateVersion
|
const requestAuthStateVersion = this.authStateVersion
|
||||||
let restorePromise!: Promise<string>
|
const restorePromise = (async () => {
|
||||||
restorePromise = (async () => {
|
|
||||||
const accessToken = await this.coordinatedRefresh()
|
const accessToken = await this.coordinatedRefresh()
|
||||||
if (requestAuthStateVersion !== this.authStateVersion) {
|
if (requestAuthStateVersion !== this.authStateVersion) {
|
||||||
throw new Error('Auth state changed during session restore')
|
throw new Error('Auth state changed during session restore')
|
||||||
|
|||||||
@@ -9,7 +9,10 @@
|
|||||||
@update:model-value="handleDialogUpdate"
|
@update:model-value="handleDialogUpdate"
|
||||||
>
|
>
|
||||||
<div class="space-y-3.5">
|
<div class="space-y-3.5">
|
||||||
<nav class="grid grid-cols-3 gap-1.5 rounded-xl bg-muted/40 p-1.5" aria-label="批量导入步骤">
|
<nav
|
||||||
|
class="grid grid-cols-3 gap-1.5 rounded-xl bg-muted/40 p-1.5"
|
||||||
|
aria-label="批量导入步骤"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
v-for="step in steps"
|
v-for="step in steps"
|
||||||
:key="step.id"
|
:key="step.id"
|
||||||
@@ -37,8 +40,12 @@
|
|||||||
<div class="flex min-w-0 items-center gap-3">
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-foreground text-xs font-semibold text-background">1</span>
|
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-foreground text-xs font-semibold text-background">1</span>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h3 class="text-balance text-sm font-semibold text-foreground">粘贴名称与 Key</h3>
|
<h3 class="text-balance text-sm font-semibold text-foreground">
|
||||||
<p class="text-pretty text-[11px] leading-4 text-muted-foreground">每行一条,仅接受四个短横线分隔</p>
|
粘贴名称与 Key
|
||||||
|
</h3>
|
||||||
|
<p class="text-pretty text-[11px] leading-4 text-muted-foreground">
|
||||||
|
每行一条,仅接受四个短横线分隔
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -50,7 +57,10 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<Label for="provider-key-batch-input" class="sr-only">Key 列表</Label>
|
<Label
|
||||||
|
for="provider-key-batch-input"
|
||||||
|
class="sr-only"
|
||||||
|
>Key 列表</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="provider-key-batch-input"
|
id="provider-key-batch-input"
|
||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
@@ -68,7 +78,10 @@
|
|||||||
>
|
>
|
||||||
{{ item.lineNumber ? `第 ${item.lineNumber} 行:` : '' }}{{ item.message }}
|
{{ item.lineNumber ? `第 ${item.lineNumber} 行:` : '' }}{{ item.message }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="parsed.errors.length > 6" class="font-medium">
|
<div
|
||||||
|
v-if="parsed.errors.length > 6"
|
||||||
|
class="font-medium"
|
||||||
|
>
|
||||||
另有 {{ parsed.errors.length - 6 }} 个问题
|
另有 {{ parsed.errors.length - 6 }} 个问题
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,7 +111,10 @@
|
|||||||
>{{ item }}</span>
|
>{{ item }}</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<Badge :variant="selectedApiFormats.length > 0 ? 'success' : 'destructive'" class="ml-auto shrink-0 tabular-nums">
|
<Badge
|
||||||
|
:variant="selectedApiFormats.length > 0 ? 'success' : 'destructive'"
|
||||||
|
class="ml-auto shrink-0 tabular-nums"
|
||||||
|
>
|
||||||
{{ selectedApiFormats.length }} 种格式
|
{{ selectedApiFormats.length }} 种格式
|
||||||
</Badge>
|
</Badge>
|
||||||
</header>
|
</header>
|
||||||
@@ -123,12 +139,18 @@
|
|||||||
<header class="flex min-h-[72px] items-center gap-3 border-b border-border/60 bg-muted/15 px-4 py-3">
|
<header class="flex min-h-[72px] items-center gap-3 border-b border-border/60 bg-muted/15 px-4 py-3">
|
||||||
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-foreground text-xs font-semibold text-background">3</span>
|
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-foreground text-xs font-semibold text-background">3</span>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<h3 class="text-balance text-sm font-semibold">逐项确认</h3>
|
<h3 class="text-balance text-sm font-semibold">
|
||||||
<p class="text-pretty text-[11px] leading-4 text-muted-foreground">展开任意 Key 可修改内容或设置单独配置</p>
|
逐项确认
|
||||||
|
</h3>
|
||||||
|
<p class="text-pretty text-[11px] leading-4 text-muted-foreground">
|
||||||
|
展开任意 Key 可修改内容或设置单独配置
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0 text-right text-[11px] text-muted-foreground">
|
<div class="shrink-0 text-right text-[11px] text-muted-foreground">
|
||||||
<div><span class="font-semibold tabular-nums text-foreground">{{ reviewItems.length }}</span> 个 Key</div>
|
<div><span class="font-semibold tabular-nums text-foreground">{{ reviewItems.length }}</span> 个 Key</div>
|
||||||
<div v-if="customizedItemCount > 0"><span class="tabular-nums">{{ customizedItemCount }}</span> 个单独配置</div>
|
<div v-if="customizedItemCount > 0">
|
||||||
|
<span class="tabular-nums">{{ customizedItemCount }}</span> 个单独配置
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -156,9 +178,13 @@
|
|||||||
v-if="reviewErrorsByIndex.has(entry.index)"
|
v-if="reviewErrorsByIndex.has(entry.index)"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
class="shrink-0 text-[10px]"
|
class="shrink-0 text-[10px]"
|
||||||
>需修正</Badge>
|
>
|
||||||
|
需修正
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 truncate font-mono text-[10px] text-muted-foreground">
|
||||||
|
{{ maskSecret(entry.item.apiKey) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-0.5 truncate font-mono text-[10px] text-muted-foreground">{{ maskSecret(entry.item.apiKey) }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden shrink-0 items-center gap-1.5 sm:flex">
|
<div class="hidden shrink-0 items-center gap-1.5 sm:flex">
|
||||||
<span class="rounded-md bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">{{ effectiveAuthLabel(entry.item) }}</span>
|
<span class="rounded-md bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">{{ effectiveAuthLabel(entry.item) }}</span>
|
||||||
@@ -186,18 +212,30 @@
|
|||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label class="text-xs">名称</Label>
|
<Label class="text-xs">名称</Label>
|
||||||
<Input v-model="entry.item.name" class="h-10" placeholder="必填" />
|
<Input
|
||||||
|
v-model="entry.item.name"
|
||||||
|
class="h-10"
|
||||||
|
placeholder="必填"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label class="text-xs">Key</Label>
|
<Label class="text-xs">Key</Label>
|
||||||
<Input v-model="entry.item.apiKey" class="h-10 font-mono text-xs" placeholder="必填" />
|
<Input
|
||||||
|
v-model="entry.item.apiKey"
|
||||||
|
class="h-10 font-mono text-xs"
|
||||||
|
placeholder="必填"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex min-h-12 items-center justify-between gap-3 rounded-lg bg-background px-3 shadow-[0_0_0_1px_rgb(0_0_0/0.06)] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.08)]">
|
<div class="flex min-h-12 items-center justify-between gap-3 rounded-lg bg-background px-3 shadow-[0_0_0_1px_rgb(0_0_0/0.06)] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.08)]">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium">单独配置此 Key</div>
|
<div class="text-xs font-medium">
|
||||||
<div class="text-[11px] text-muted-foreground">开启后覆盖第二步中的统一配置</div>
|
单独配置此 Key
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-muted-foreground">
|
||||||
|
开启后覆盖第二步中的统一配置
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
:model-value="entry.item.customized"
|
:model-value="entry.item.customized"
|
||||||
@@ -220,7 +258,12 @@
|
|||||||
v-if="reviewErrorsByIndex.has(entry.index)"
|
v-if="reviewErrorsByIndex.has(entry.index)"
|
||||||
class="space-y-1 rounded-lg bg-destructive/5 px-3 py-2 text-[11px] text-destructive"
|
class="space-y-1 rounded-lg bg-destructive/5 px-3 py-2 text-[11px] text-destructive"
|
||||||
>
|
>
|
||||||
<div v-for="message in reviewErrorsByIndex.get(entry.index)" :key="message">{{ message }}</div>
|
<div
|
||||||
|
v-for="message in reviewErrorsByIndex.get(entry.index)"
|
||||||
|
:key="message"
|
||||||
|
>
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
@@ -230,12 +273,24 @@
|
|||||||
v-if="reviewPageCount > 1"
|
v-if="reviewPageCount > 1"
|
||||||
class="flex min-h-12 items-center justify-between gap-3 border-t border-border/60 bg-muted/10 px-3 sm:px-4"
|
class="flex min-h-12 items-center justify-between gap-3 border-t border-border/60 bg-muted/10 px-3 sm:px-4"
|
||||||
>
|
>
|
||||||
<Button variant="ghost" size="sm" class="h-9" :disabled="reviewPage === 1" @click="changeReviewPage(reviewPage - 1)">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-9"
|
||||||
|
:disabled="reviewPage === 1"
|
||||||
|
@click="changeReviewPage(reviewPage - 1)"
|
||||||
|
>
|
||||||
<ChevronLeft class="mr-1 h-4 w-4" />
|
<ChevronLeft class="mr-1 h-4 w-4" />
|
||||||
上一页
|
上一页
|
||||||
</Button>
|
</Button>
|
||||||
<span class="text-[11px] tabular-nums text-muted-foreground">{{ reviewPage }} / {{ reviewPageCount }}</span>
|
<span class="text-[11px] tabular-nums text-muted-foreground">{{ reviewPage }} / {{ reviewPageCount }}</span>
|
||||||
<Button variant="ghost" size="sm" class="h-9" :disabled="reviewPage === reviewPageCount" @click="changeReviewPage(reviewPage + 1)">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-9"
|
||||||
|
:disabled="reviewPage === reviewPageCount"
|
||||||
|
@click="changeReviewPage(reviewPage + 1)"
|
||||||
|
>
|
||||||
下一页
|
下一页
|
||||||
<ChevronRight class="ml-1 h-4 w-4" />
|
<ChevronRight class="ml-1 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -245,14 +300,35 @@
|
|||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
<div class="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
<Button class="w-full sm:w-auto" variant="outline" :disabled="importing" @click="handleBack">
|
<Button
|
||||||
<ArrowLeft v-if="currentStep > 1" class="mr-2 h-4 w-4" />
|
class="w-full sm:w-auto"
|
||||||
|
variant="outline"
|
||||||
|
:disabled="importing"
|
||||||
|
@click="handleBack"
|
||||||
|
>
|
||||||
|
<ArrowLeft
|
||||||
|
v-if="currentStep > 1"
|
||||||
|
class="mr-2 h-4 w-4"
|
||||||
|
/>
|
||||||
{{ currentStep === 1 ? '取消' : '上一步' }}
|
{{ currentStep === 1 ? '取消' : '上一步' }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button class="w-full sm:w-auto" :disabled="primaryActionDisabled" @click="handlePrimaryAction">
|
<Button
|
||||||
<Loader2 v-if="importing" class="mr-2 h-4 w-4 animate-spin" />
|
class="w-full sm:w-auto"
|
||||||
<ListPlus v-else-if="currentStep === 3" class="mr-2 h-4 w-4" />
|
:disabled="primaryActionDisabled"
|
||||||
<ArrowRight v-else class="mr-2 h-4 w-4" />
|
@click="handlePrimaryAction"
|
||||||
|
>
|
||||||
|
<Loader2
|
||||||
|
v-if="importing"
|
||||||
|
class="mr-2 h-4 w-4 animate-spin"
|
||||||
|
/>
|
||||||
|
<ListPlus
|
||||||
|
v-else-if="currentStep === 3"
|
||||||
|
class="mr-2 h-4 w-4"
|
||||||
|
/>
|
||||||
|
<ArrowRight
|
||||||
|
v-else
|
||||||
|
class="mr-2 h-4 w-4"
|
||||||
|
/>
|
||||||
{{ primaryActionLabel }}
|
{{ primaryActionLabel }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,8 +28,12 @@
|
|||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="api_key">API Key</SelectItem>
|
<SelectItem value="api_key">
|
||||||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
API Key
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="bearer">
|
||||||
|
Bearer Token
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,8 +116,12 @@
|
|||||||
|
|
||||||
<div class="flex min-h-12 items-center justify-between gap-3 rounded-lg bg-background px-3 shadow-[0_0_0_1px_rgb(0_0_0/0.06),0_1px_2px_rgb(0_0_0/0.04)] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.08)]">
|
<div class="flex min-h-12 items-center justify-between gap-3 rounded-lg bg-background px-3 shadow-[0_0_0_1px_rgb(0_0_0/0.06),0_1px_2px_rgb(0_0_0/0.04)] dark:shadow-[0_0_0_1px_rgb(255_255_255/0.08)]">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-xs font-medium">导入后立即启用</div>
|
<div class="text-xs font-medium">
|
||||||
<div class="text-[11px] text-muted-foreground">关闭后仍会创建,但不会进入调度</div>
|
导入后立即启用
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] text-muted-foreground">
|
||||||
|
关闭后仍会创建,但不会进入调度
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
:model-value="settings.is_active"
|
:model-value="settings.is_active"
|
||||||
|
|||||||
@@ -100,6 +100,51 @@ describe('auth store logout', () => {
|
|||||||
expect(clearAuthMock).toHaveBeenCalledWith(false, false)
|
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 () => {
|
it('preserves an existing access token when a forced restore fails', async () => {
|
||||||
getTokenMock.mockReturnValue('still-valid-access-token')
|
getTokenMock.mockReturnValue('still-valid-access-token')
|
||||||
restoreSessionMock.mockRejectedValue(new Error('temporary refresh conflict'))
|
restoreSessionMock.mockRejectedValue(new Error('temporary refresh conflict'))
|
||||||
|
|||||||
@@ -60,8 +60,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
sessionRestoreAttempted = true
|
sessionRestoreAttempted = true
|
||||||
const requestAuthStateVersion = authStateVersion
|
const requestAuthStateVersion = authStateVersion
|
||||||
const requestToken = token.value
|
const requestToken = token.value
|
||||||
let request!: Promise<boolean>
|
const request = (async () => {
|
||||||
request = (async () => {
|
|
||||||
try {
|
try {
|
||||||
const accessToken = await apiClient.restoreSession(notifyOtherTabs)
|
const accessToken = await apiClient.restoreSession(notifyOtherTabs)
|
||||||
if (requestAuthStateVersion !== authStateVersion) {
|
if (requestAuthStateVersion !== authStateVersion) {
|
||||||
@@ -82,12 +81,12 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
} finally {
|
}
|
||||||
|
})().finally(() => {
|
||||||
if (sessionRestorePromise === request) {
|
if (sessionRestorePromise === request) {
|
||||||
sessionRestorePromise = null
|
sessionRestorePromise = null
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})()
|
|
||||||
|
|
||||||
sessionRestorePromise = request
|
sessionRestorePromise = request
|
||||||
return request
|
return request
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import {
|
|||||||
safeInternalNavigationPath,
|
safeInternalNavigationPath,
|
||||||
} from '../navigationSecurity'
|
} 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', () => {
|
describe('safeInternalNavigationPath', () => {
|
||||||
it('keeps normalized same-origin paths, queries, and fragments', () => {
|
it('keeps normalized same-origin paths, queries, and fragments', () => {
|
||||||
expect(safeInternalNavigationPath('/dashboard/../dashboard/api-keys?tab=active#key-1')).toBe(
|
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([
|
it.each([
|
||||||
'https://attacker.example/steal',
|
'https://attacker.example/steal',
|
||||||
'//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([
|
it.each([
|
||||||
'javascript:alert(1)',
|
'javascript:alert(1)',
|
||||||
'data:text/html,attack',
|
'data:text/html,attack',
|
||||||
@@ -53,6 +81,17 @@ describe('safeExternalWebUrl', () => {
|
|||||||
expect(safeExternalWebUrl(value)).toBe(expected)
|
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([
|
it.each([
|
||||||
'javascript:alert(1)',
|
'javascript:alert(1)',
|
||||||
'data:text/html,attack',
|
'data:text/html,attack',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const INTERNAL_NAVIGATION_BASE = 'https://aether.invalid'
|
const INTERNAL_NAVIGATION_BASE = 'https://aether.invalid'
|
||||||
const UNSAFE_EXTERNAL_URL_CHARACTERS = /[\\\u0000-\u001f\u007f]/
|
const UNSAFE_EXTERNAL_URL_CHARACTERS = /[\\\p{Cc}]/u
|
||||||
|
|
||||||
function safeAbsoluteExternalUrl(
|
function safeAbsoluteExternalUrl(
|
||||||
value: string | null | undefined,
|
value: string | null | undefined,
|
||||||
@@ -38,7 +38,7 @@ export function safeInternalNavigationPath(value: string | null | undefined): st
|
|||||||
!candidate.startsWith('/')
|
!candidate.startsWith('/')
|
||||||
|| candidate.startsWith('//')
|
|| candidate.startsWith('//')
|
||||||
|| candidate.includes('\\')
|
|| candidate.includes('\\')
|
||||||
|| /[\u0000-\u001f\u007f]/.test(candidate)
|
|| /\p{Cc}/u.test(candidate)
|
||||||
) {
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user