fix(web): 修正访问密钥安全边界

This commit is contained in:
tbphp committed 2026-07-25 18:42:29 +08:00
1 parent 82fc793e9b
commit 68639a2338
5 files changed
+146 -6

No files matched your search

@@ -144,6 +144,35 @@ describe('AccessKeyDrawer', () => {
wrapper.unmount()
})
it('keeps Save disabled and sends no PUT after a filter is toggled off and back on', async () => {
const reordered: AccessKeyDto = {
...existing,
filters: {
groups: [7],
protocols: ['openai-response', 'openai'],
models: ['legacy-free', 'gpt-4.1'],
},
}
const request = vi.fn() as ApiClient['request']
const { wrapper } = await mountDrawer(request, { open: true, accessKey: reordered })
const protocolCheckboxes = document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
const openAIResponse = protocolCheckboxes[4]
if (!openAIResponse) throw new Error('missing openai-response checkbox')
openAIResponse.checked = false
openAIResponse.dispatchEvent(new Event('change', { bubbles: true }))
await flushPromises()
openAIResponse.checked = true
openAIResponse.dispatchEvent(new Event('change', { bubbles: true }))
await flushPromises()
const save = element<HTMLButtonElement>('[data-test="access-key-save"]')
expect(save.disabled).toBe(true)
save.click()
expect(request).not.toHaveBeenCalled()
wrapper.unmount()
})
it('requires a non-negative safe-integer RPM and retains input after generic errors', async () => {
const request = vi.fn().mockRejectedValue(new Error(canary)) as ApiClient['request']
const { wrapper } = await mountDrawer(request)
@@ -198,4 +227,67 @@ describe('AccessKeyDrawer', () => {
expect(document.body.textContent).not.toContain(canary)
wrapper.unmount()
})
it('ignores an ordinary late rejection after the Drawer closes', async () => {
let rejectRequest!: (error: unknown) => void
const request = vi.fn(
() =>
new Promise<AccessKeyDto>((_, reject) => {
rejectRequest = reject
}),
) as ApiClient['request']
const { queryClient: client, wrapper } = await mountDrawer(request)
const invalidate = vi.spyOn(client, 'invalidateQueries').mockResolvedValue()
const setupState = wrapper.vm.$.setupState as { failed: boolean }
const name = element<HTMLInputElement>('[data-test="access-key-name"]')
name.value = 'late-reject-client'
name.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
element<HTMLButtonElement>('[data-test="access-key-save"]').click()
await flushPromises()
await wrapper.setProps({ open: false })
rejectRequest(new Error(canary))
await flushPromises()
expect(setupState.failed).toBe(false)
expect(invalidate).not.toHaveBeenCalled()
const snapshotSafeOutput = JSON.stringify({
render: document.body.innerHTML,
emitted: wrapper.emitted(),
})
expect(snapshotSafeOutput).not.toContain(canary)
expect(snapshotSafeOutput).not.toContain('Unable to save')
wrapper.unmount()
})
it('ignores an ordinary late rejection after unmount', async () => {
let rejectRequest!: (error: unknown) => void
const request = vi.fn(
() =>
new Promise<AccessKeyDto>((_, reject) => {
rejectRequest = reject
}),
) as ApiClient['request']
const { queryClient: client, wrapper } = await mountDrawer(request)
const invalidate = vi.spyOn(client, 'invalidateQueries').mockResolvedValue()
const setupState = wrapper.vm.$.setupState as { failed: boolean }
const emitted = wrapper.emitted()
const name = element<HTMLInputElement>('[data-test="access-key-name"]')
name.value = 'unmounted-reject-client'
name.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
element<HTMLButtonElement>('[data-test="access-key-save"]').click()
await flushPromises()
wrapper.unmount()
rejectRequest(new Error(canary))
await flushPromises()
expect(setupState.failed).toBe(false)
expect(invalidate).not.toHaveBeenCalled()
const snapshotSafeOutput = JSON.stringify({ render: document.body.innerHTML, emitted })
expect(snapshotSafeOutput).not.toContain(canary)
expect(snapshotSafeOutput).not.toContain('Unable to save')
})
})
@@ -145,6 +145,7 @@ async function save(): Promise<void> {
}
await queryClient.invalidateQueries({ queryKey: controlQueryKeys.accessKeys.list() })
} catch (error: unknown) {
if (controller !== activeController || !props.open) return
if (!(error instanceof RequestCancelledError)) failed.value = true
} finally {
if (controller === activeController) controller = undefined
@@ -37,6 +37,15 @@ function queryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
function serializeStorage(storage: Storage): string {
return JSON.stringify(
Array.from({ length: storage.length }, (_, index) => {
const key = storage.key(index)
return [key, key === null ? null : storage.getItem(key)]
}),
)
}
async function mountView(request: ApiClient['request']) {
const client = queryClient()
const mounted = await mountApp(AccessKeysView, {
@@ -73,6 +82,8 @@ describe('AccessKeysView', () => {
it('masks by default, reveals and copies only locally, then removes gcTime-zero plaintext on unmount', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
sessionStorage.setItem('arbitrary.session.slot', 'safe-session-value')
localStorage.setItem('arbitrary.local.slot', 'safe-local-value')
const request = vi.fn(async (path: string, options?: ApiRequestOptions) => {
if (path === '/api/access-keys' && options?.method === 'GET') return keys
if (path === '/api/groups' && options?.method === 'GET') return groups
@@ -89,8 +100,8 @@ describe('AccessKeysView', () => {
expect(JSON.stringify(router.currentRoute.value)).not.toContain(canary)
expect(JSON.stringify(controlQueryKeys)).not.toContain(canary)
expect(sessionStorage.getItem('gpt-load.access-key')).toBeNull()
expect(localStorage.getItem('gpt-load.access-key')).toBeNull()
expect(serializeStorage(sessionStorage)).not.toContain(canary)
expect(serializeStorage(localStorage)).not.toContain(canary)
expect(client.getMutationCache().getAll()).toHaveLength(0)
wrapper.unmount()
@@ -98,6 +109,11 @@ describe('AccessKeysView', () => {
expect(client.getQueryData(controlQueryKeys.accessKeys.list())).toBeUndefined()
expect(JSON.stringify(client.getQueryCache().getAll())).not.toContain(canary)
expect(document.body.textContent).not.toContain(canary)
const snapshotSafeOutput = JSON.stringify({
render: document.body.innerHTML,
emitted: wrapper.emitted(),
})
expect(snapshotSafeOutput).not.toContain(canary)
})
it('retains masked stale list data and never renders generic error details', async () => {
@@ -39,6 +39,25 @@ describe('AccessKey request normalization', () => {
expect(buildAccessKeyUpdatePatch(base, draft)).toEqual({})
})
it('treats filter arrays with the same members in a different order as unchanged sets', () => {
const reorderedBase: AccessKeyDto = {
...base,
filters: {
groups: [7, 3],
protocols: ['openai-response', 'anthropic'],
models: ['known', 'free-entry'],
},
}
const draft = createAccessKeyDraft(reorderedBase)
draft.filters = {
groups: [3, 7],
protocols: ['anthropic', 'openai-response'],
models: ['free-entry', 'known'],
}
expect(buildAccessKeyUpdatePatch(reorderedBase, draft)).toEqual({})
})
it('sends only normalized dirty fields and preserves empty filters as exact arrays', () => {
const draft = createAccessKeyDraft(base)
draft.status = 'disabled'
@@ -45,11 +45,23 @@ export function buildCreateAccessKeyInput(draft: AccessKeyDraft): CreateAccessKe
}
}
function compareStrings(left: string, right: string): number {
if (left < right) return -1
if (left > right) return 1
return 0
}
function canonicalFilters(filters: AccessKeyFiltersDto): AccessKeyFiltersDto {
const normalized = normalizeAccessKeyFilters(filters)
return {
groups: [...normalized.groups].sort((left, right) => left - right),
protocols: [...normalized.protocols].sort(compareStrings),
models: [...normalized.models].sort(compareStrings),
}
}
function equalFilters(left: AccessKeyFiltersDto, right: AccessKeyFiltersDto): boolean {
return (
JSON.stringify(normalizeAccessKeyFilters(left)) ===
JSON.stringify(normalizeAccessKeyFilters(right))
)
return JSON.stringify(canonicalFilters(left)) === JSON.stringify(canonicalFilters(right))
}
export function buildAccessKeyUpdatePatch(