From 386f321d3cfc052443a5b68eb3dc022d66665c51 Mon Sep 17 00:00:00 2001 From: wucm667 Date: Mon, 21 Sep 2026 12:41:01 +0800 Subject: [PATCH] fix(usage): export missing reasoning effort cleanly --- frontend/src/views/user/UsageView.vue | 1 + .../views/user/__tests__/UsageView.spec.ts | 35 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/user/UsageView.vue b/frontend/src/views/user/UsageView.vue index 033064d109..c188248069 100644 --- a/frontend/src/views/user/UsageView.vue +++ b/frontend/src/views/user/UsageView.vue @@ -626,6 +626,7 @@ const escapeCSVValue = (value: unknown): string => { if (value == null) return '' const str = String(value) const escaped = str.replace(/"/g, '""') + if (str === '-') return str if (/^[=+\-@\t\r]/.test(str)) return `"\'${escaped}"` if (/[,"\n\r]/.test(str)) return `"${escaped}"` return str diff --git a/frontend/src/views/user/__tests__/UsageView.spec.ts b/frontend/src/views/user/__tests__/UsageView.spec.ts index 737d88aa70..1ded62c5b7 100644 --- a/frontend/src/views/user/__tests__/UsageView.spec.ts +++ b/frontend/src/views/user/__tests__/UsageView.spec.ts @@ -399,7 +399,7 @@ describe('user UsageView', () => { expect(csvContent.startsWith('\uFEFF')).toBe(true) expect(csvContent.slice(1)).toBe([ 'Time,API Key Name,Model,Reasoning Effort,Inbound Endpoint,IP Address,Type,Billing Mode,Input Tokens,Output Tokens,Cache Read Tokens,Cache Creation Tokens,Rate Multiplier,Billed Cost,Original Cost,First Token (ms),Duration (ms)', - '2026-03-08T00:00:00Z,demo-key,gpt-5.4,"\'-",,203.0.113.10,Sync,Token,4057,101,278272,4,1,0.09288300,0.09288300,12,345', + '2026-03-08T00:00:00Z,demo-key,gpt-5.4,-,,203.0.113.10,Sync,Token,4057,101,278272,4,1,0.09288300,0.09288300,12,345', ].join('\n')) expect(csvContent).toContain('IP Address') expect(csvContent).toContain('203.0.113.10') @@ -415,6 +415,39 @@ describe('user UsageView', () => { clickSpy.mockRestore() }) + it('keeps formula-injection protection for dangerous exported values', async () => { + query.mockResolvedValue({ + items: [{ ...usageLog, api_key: { name: '-1+1' } }], + total: 1, + pages: 1, + }) + const wrapper = mountUsageView() + await flushPromises() + + let csvContent = '' + const OriginalBlob = globalThis.Blob + vi.stubGlobal('Blob', vi.fn((parts: BlobPart[], options?: BlobPropertyBag) => { + csvContent = parts.map((part) => String(part)).join('') + return new OriginalBlob(parts, options) + })) + const originalCreateObjectURL = window.URL.createObjectURL + const originalRevokeObjectURL = window.URL.revokeObjectURL + window.URL.createObjectURL = vi.fn(() => 'blob:usage-export') as typeof window.URL.createObjectURL + window.URL.revokeObjectURL = vi.fn(() => {}) as typeof window.URL.revokeObjectURL + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + + await (wrapper.vm as any).exportToCSV() + + expect(csvContent).toContain(',"\'-1+1",gpt-5.4,-,') + expect(showSuccess).toHaveBeenCalled() + + window.URL.createObjectURL = originalCreateObjectURL + window.URL.revokeObjectURL = originalRevokeObjectURL + vi.unstubAllGlobals() + clickSpy.mockRestore() + wrapper.unmount() + }) + it('keeps the initial filters, sort, and filename while exporting multiple pages', async () => { const pageResponse = { items: [usageLog], total: 101, pages: 2 } query.mockResolvedValue(pageResponse)