From 68639a23381d2212fbf153a67fa72fd612ee121d Mon Sep 17 00:00:00 2001 From: tbphp Date: Sat, 25 Jul 2026 18:42:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E4=BF=AE=E6=AD=A3=E8=AE=BF?= =?UTF-8?q?=E9=97=AE=E5=AF=86=E9=92=A5=E5=AE=89=E5=85=A8=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../access-keys/AccessKeyDrawer.test.ts | 92 +++++++++++++++++++ .../features/access-keys/AccessKeyDrawer.vue | 1 + .../access-keys/AccessKeysView.test.ts | 20 +++- .../access-keys/access-key-patch.test.ts | 19 ++++ .../features/access-keys/access-key-patch.ts | 20 +++- 5 files changed, 146 insertions(+), 6 deletions(-) diff --git a/web/src/features/access-keys/AccessKeyDrawer.test.ts b/web/src/features/access-keys/AccessKeyDrawer.test.ts index e64c17e5..a8575b53 100644 --- a/web/src/features/access-keys/AccessKeyDrawer.test.ts +++ b/web/src/features/access-keys/AccessKeyDrawer.test.ts @@ -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('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('[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((_, 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('[data-test="access-key-name"]') + name.value = 'late-reject-client' + name.dispatchEvent(new Event('input', { bubbles: true })) + await flushPromises() + element('[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((_, 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('[data-test="access-key-name"]') + name.value = 'unmounted-reject-client' + name.dispatchEvent(new Event('input', { bubbles: true })) + await flushPromises() + element('[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') + }) }) diff --git a/web/src/features/access-keys/AccessKeyDrawer.vue b/web/src/features/access-keys/AccessKeyDrawer.vue index 0822d2f9..ee7135ac 100644 --- a/web/src/features/access-keys/AccessKeyDrawer.vue +++ b/web/src/features/access-keys/AccessKeyDrawer.vue @@ -145,6 +145,7 @@ async function save(): Promise { } 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 diff --git a/web/src/features/access-keys/AccessKeysView.test.ts b/web/src/features/access-keys/AccessKeysView.test.ts index a6b6a98e..278288a0 100644 --- a/web/src/features/access-keys/AccessKeysView.test.ts +++ b/web/src/features/access-keys/AccessKeysView.test.ts @@ -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 () => { diff --git a/web/src/features/access-keys/access-key-patch.test.ts b/web/src/features/access-keys/access-key-patch.test.ts index d24b9dfa..8737bb3b 100644 --- a/web/src/features/access-keys/access-key-patch.test.ts +++ b/web/src/features/access-keys/access-key-patch.test.ts @@ -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' diff --git a/web/src/features/access-keys/access-key-patch.ts b/web/src/features/access-keys/access-key-patch.ts index 0133fc11..b9af3596 100644 --- a/web/src/features/access-keys/access-key-patch.ts +++ b/web/src/features/access-keys/access-key-patch.ts @@ -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(