fix(web): 修正访问密钥安全边界
This commit is contained in:
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(
|
||||
|
||||
Reference in new issue
Block a user