This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 22cfc4536b fix(export): URL prefix handling for subdirectory
deployments (#36771)
22cfc4536b is described below
commit 22cfc4536b17120227778081d6ef9ba264c2215b
Author: Joe Li <[email protected]>
AuthorDate: Mon Jan 12 10:58:59 2026 -0800
fix(export): URL prefix handling for subdirectory deployments (#36771)
Co-authored-by: Claude Opus 4.5 <[email protected]>
---
.../SqlLab/components/ResultSet/ResultSet.test.tsx | 141 +++
.../src/SqlLab/components/ResultSet/index.tsx | 2 +-
.../useStreamingExport.test.ts | 959 +++++++++++++++++++++
.../StreamingExportModal/useStreamingExport.ts | 57 +-
.../src/explore/exploreUtils/exportChart.test.ts | 173 ++++
superset-frontend/src/utils/export.test.ts | 51 ++
superset-frontend/src/utils/export.ts | 5 +-
7 files changed, 1381 insertions(+), 7 deletions(-)
diff --git
a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
index 29e2fd162f..a45e38bf0c 100644
--- a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
+++ b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx
@@ -47,6 +47,19 @@ jest.mock('src/components/ErrorMessage', () => ({
ErrorMessageWithStackTrace: () => <div data-test="error-message">Error</div>,
}));
+// Mock useStreamingExport to capture startExport calls
+const mockStartExport = jest.fn();
+const mockResetExport = jest.fn();
+const mockCancelExport = jest.fn();
+jest.mock('src/components/StreamingExportModal/useStreamingExport', () => ({
+ useStreamingExport: () => ({
+ startExport: mockStartExport,
+ resetExport: mockResetExport,
+ cancelExport: mockCancelExport,
+ progress: { status: 'streaming', rowsProcessed: 0 },
+ }),
+}));
+
jest.mock(
'react-virtualized-auto-sizer',
() =>
@@ -160,6 +173,7 @@ describe('ResultSet', () => {
beforeEach(() => {
applicationRootMock.mockReturnValue('');
+ mockStartExport.mockClear();
});
// Add cleanup after each test
@@ -657,4 +671,131 @@ describe('ResultSet', () => {
const resultsCalls = fetchMock.calls('glob:*/api/v1/sqllab/results/*');
expect(resultsCalls).toHaveLength(1);
});
+
+ test('should use non-streaming export (href) when rows below threshold',
async () => {
+ // This test validates that when rows < CSV_STREAMING_ROW_THRESHOLD,
+ // the component uses the direct download href instead of streaming export.
+ const appRoot = '/superset';
+ applicationRootMock.mockReturnValue(appRoot);
+
+ // Create a query with rows BELOW the threshold
+ const smallQuery = {
+ ...queries[0],
+ rows: 500, // Below the 1000 threshold
+ limitingFactor: 'NOT_LIMITED',
+ };
+
+ const { getByTestId } = setup(
+ mockedProps,
+ mockStore({
+ ...initialState,
+ user: {
+ ...user,
+ roles: {
+ sql_lab: [['can_export_csv', 'SQLLab']],
+ },
+ },
+ sqlLab: {
+ ...initialState.sqlLab,
+ queries: {
+ [smallQuery.id]: smallQuery,
+ },
+ },
+ common: {
+ conf: {
+ CSV_STREAMING_ROW_THRESHOLD: 1000,
+ },
+ },
+ }),
+ );
+
+ await waitFor(() => {
+ expect(getByTestId('export-csv-button')).toBeInTheDocument();
+ });
+
+ const exportButton = getByTestId('export-csv-button');
+
+ // Non-streaming export should have href attribute with prefixed URL
+ expect(exportButton).toHaveAttribute(
+ 'href',
+ expect.stringMatching(new RegExp(`^${appRoot}/api/v1/sqllab/export/`)),
+ );
+
+ // Click should NOT trigger startExport for non-streaming
+ fireEvent.click(exportButton);
+ expect(mockStartExport).not.toHaveBeenCalled();
+ });
+
+ test.each([
+ {
+ name: 'no prefix (default deployment)',
+ appRoot: '',
+ expectedUrl: '/api/v1/sqllab/export_streaming/',
+ },
+ {
+ name: 'with subdirectory prefix',
+ appRoot: '/superset',
+ expectedUrl: '/superset/api/v1/sqllab/export_streaming/',
+ },
+ {
+ name: 'with nested subdirectory prefix',
+ appRoot: '/my-app/superset',
+ expectedUrl: '/my-app/superset/api/v1/sqllab/export_streaming/',
+ },
+ ])(
+ 'streaming export URL respects app root configuration: $name',
+ async ({ appRoot, expectedUrl }) => {
+ // This test validates that streaming export startExport receives the
correct URL
+ // based on the applicationRoot configuration.
+ applicationRootMock.mockReturnValue(appRoot);
+
+ // Create a query with enough rows to trigger streaming export (>=
threshold)
+ const largeQuery = {
+ ...queries[0],
+ rows: 5000, // Above the default 1000 threshold
+ limitingFactor: 'NOT_LIMITED',
+ };
+
+ const { getByTestId } = setup(
+ mockedProps,
+ mockStore({
+ ...initialState,
+ user: {
+ ...user,
+ roles: {
+ sql_lab: [['can_export_csv', 'SQLLab']],
+ },
+ },
+ sqlLab: {
+ ...initialState.sqlLab,
+ queries: {
+ [largeQuery.id]: largeQuery,
+ },
+ },
+ common: {
+ conf: {
+ CSV_STREAMING_ROW_THRESHOLD: 1000,
+ },
+ },
+ }),
+ );
+
+ await waitFor(() => {
+ expect(getByTestId('export-csv-button')).toBeInTheDocument();
+ });
+
+ const exportButton = getByTestId('export-csv-button');
+ fireEvent.click(exportButton);
+
+ // Verify startExport was called exactly once
+ expect(mockStartExport).toHaveBeenCalledTimes(1);
+
+ // The URL should match the expected prefixed URL
+ expect(mockStartExport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: expectedUrl,
+ }),
+ );
+ },
+ );
});
diff --git a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
index def05372e7..439bf37ca2 100644
--- a/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
+++ b/superset-frontend/src/SqlLab/components/ResultSet/index.tsx
@@ -424,7 +424,7 @@ const ResultSet = ({
setShowStreamingModal(true);
startExport({
- url: '/api/v1/sqllab/export_streaming/',
+ url: makeUrl('/api/v1/sqllab/export_streaming/'),
payload: { client_id: query.id },
exportType: 'csv',
expectedRows: rows,
diff --git
a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts
b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts
index 03241082dd..8f114c6e46 100644
---
a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts
+++
b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.test.ts
@@ -16,10 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { TextEncoder, TextDecoder } from 'util';
import { renderHook, act } from '@testing-library/react-hooks';
+import { waitFor } from '@testing-library/react';
import { useStreamingExport } from './useStreamingExport';
import { ExportStatus } from './StreamingExportModal';
+// Polyfill TextEncoder/TextDecoder for Jest environment
+global.TextEncoder = TextEncoder;
+global.TextDecoder = TextDecoder as typeof global.TextDecoder;
+
// Mock SupersetClient
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
@@ -28,6 +34,15 @@ jest.mock('@superset-ui/core', () => ({
},
}));
+// Mock pathUtils and getBootstrapData for URL prefix guard tests
+jest.mock('src/utils/pathUtils', () => ({
+ makeUrl: jest.fn((path: string) => path),
+}));
+
+jest.mock('src/utils/getBootstrapData', () => ({
+ applicationRoot: jest.fn(() => ''),
+}));
+
global.URL.createObjectURL = jest.fn(() => 'blob:mock-url');
global.URL.revokeObjectURL = jest.fn();
@@ -35,6 +50,7 @@ global.fetch = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
+ global.fetch = jest.fn();
});
test('useStreamingExport initializes with default progress state', () => {
@@ -124,3 +140,946 @@ test('useStreamingExport cleans up on unmount', () => {
// Cleanup should not throw errors
expect(true).toBe(true);
});
+
+test('retryExport reuses the same URL from the original startExport call',
async () => {
+ // This test ensures that retryExport uses the exact same URL that was
passed to startExport,
+ // which is important for subdirectory deployments where the URL is already
prefixed.
+ const originalUrl = '/superset/api/v1/sqllab/export_streaming/';
+ const mockFetch = jest.fn().mockResolvedValue({
+ ok: true,
+ headers: new Headers({
+ 'Content-Disposition': 'attachment; filename="export.csv"',
+ }),
+ body: {
+ getReader: () => ({
+ read: jest.fn().mockResolvedValue({ done: true, value: undefined }),
+ }),
+ },
+ });
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ // First call with startExport
+ act(() => {
+ result.current.startExport({
+ url: originalUrl,
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ expectedRows: 100,
+ });
+ });
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+ expect(mockFetch).toHaveBeenCalledWith(originalUrl, expect.any(Object));
+
+ // Reset mock to track retry call
+ mockFetch.mockClear();
+
+ // Reset the export state so we can retry
+ act(() => {
+ result.current.resetExport();
+ });
+
+ // Call retryExport - should reuse the same URL
+ act(() => {
+ result.current.retryExport();
+ });
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+ // Retry should use the exact same URL that was passed to startExport
+ expect(mockFetch).toHaveBeenCalledWith(originalUrl, expect.any(Object));
+});
+
+test('sets ERROR status and calls onError when fetch rejects', async () => {
+ const errorMessage = 'Network error';
+ const mockFetch = jest.fn().mockRejectedValue(new Error(errorMessage));
+ global.fetch = mockFetch;
+
+ const onError = jest.fn();
+ const { result } = renderHook(() => useStreamingExport({ onError }));
+
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/sqllab/export_streaming/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ expectedRows: 100,
+ });
+ });
+
+ // Wait for fetch to be called and error to be processed
+ await waitFor(() => {
+ expect(result.current.progress.status).toBe(ExportStatus.ERROR);
+ });
+
+ // Verify onError was called exactly once with the error message
+ expect(onError).toHaveBeenCalledTimes(1);
+ expect(onError).toHaveBeenCalledWith(errorMessage);
+});
+
+// URL prefix guard tests - prevent regression of missing app root prefix
+const { applicationRoot } = jest.requireMock('src/utils/getBootstrapData');
+const { makeUrl } = jest.requireMock('src/utils/pathUtils');
+
+const createPrefixTestMockFetch = () =>
+ jest.fn().mockResolvedValue({
+ ok: true,
+ headers: new Headers({
+ 'Content-Disposition': 'attachment; filename="export.csv"',
+ }),
+ body: {
+ getReader: () => ({
+ read: jest.fn().mockResolvedValue({ done: true, value: undefined }),
+ }),
+ },
+ });
+
+test('URL prefix guard applies prefix to unprefixed relative URL when app root
is configured', async () => {
+ const appRoot = '/superset';
+ applicationRoot.mockReturnValue(appRoot);
+ makeUrl.mockImplementation((path: string) => `${appRoot}${path}`);
+
+ const mockFetch = createPrefixTestMockFetch();
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/sqllab/export_streaming/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/superset/api/v1/sqllab/export_streaming/',
+ expect.any(Object),
+ );
+});
+
+test('URL prefix guard does not double-prefix URL that already has app root',
async () => {
+ const appRoot = '/superset';
+ applicationRoot.mockReturnValue(appRoot);
+ makeUrl.mockImplementation((path: string) => `${appRoot}${path}`);
+
+ const mockFetch = createPrefixTestMockFetch();
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: '/superset/api/v1/sqllab/export_streaming/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/superset/api/v1/sqllab/export_streaming/',
+ expect.any(Object),
+ );
+});
+
+test('URL prefix guard leaves URL unchanged when no app root is configured',
async () => {
+ applicationRoot.mockReturnValue('');
+
+ const mockFetch = createPrefixTestMockFetch();
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/sqllab/export_streaming/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/api/v1/sqllab/export_streaming/',
+ expect.any(Object),
+ );
+});
+
+test('URL prefix guard normalizes relative URL without leading slash and
applies prefix', async () => {
+ const appRoot = '/superset';
+ applicationRoot.mockReturnValue(appRoot);
+ makeUrl.mockImplementation((path: string) => `${appRoot}${path}`);
+
+ const mockFetch = createPrefixTestMockFetch();
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: 'api/v1/sqllab/export_streaming/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ // Should add leading slash and apply prefix
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/superset/api/v1/sqllab/export_streaming/',
+ expect.any(Object),
+ );
+});
+
+test('URL prefix guard normalizes non-slash URL to leading slash when no app
root configured', async () => {
+ applicationRoot.mockReturnValue('');
+ makeUrl.mockImplementation((path: string) => path);
+
+ const mockFetch = createPrefixTestMockFetch();
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: 'api/v1/sqllab/export_streaming/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ // Should normalize to leading slash even without app root
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/api/v1/sqllab/export_streaming/',
+ expect.any(Object),
+ );
+});
+
+test('URL prefix guard leaves absolute URLs (https) unchanged', async () => {
+ const appRoot = '/superset';
+ applicationRoot.mockReturnValue(appRoot);
+ makeUrl.mockImplementation((path: string) => `${appRoot}${path}`);
+
+ const mockFetch = createPrefixTestMockFetch();
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: 'https://external.example.com/api/export/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://external.example.com/api/export/',
+ expect.any(Object),
+ );
+});
+
+test('URL prefix guard leaves protocol-relative URLs (//host) unchanged',
async () => {
+ const appRoot = '/superset';
+ applicationRoot.mockReturnValue(appRoot);
+ makeUrl.mockImplementation((path: string) => `${appRoot}${path}`);
+
+ const mockFetch = createPrefixTestMockFetch();
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: '//external.example.com/api/export/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ '//external.example.com/api/export/',
+ expect.any(Object),
+ );
+});
+
+test('URL prefix guard correctly handles sibling paths (prefixes /app2 when
appRoot is /app)', async () => {
+ const appRoot = '/app';
+ applicationRoot.mockReturnValue(appRoot);
+ makeUrl.mockImplementation((path: string) => `${appRoot}${path}`);
+
+ const mockFetch = createPrefixTestMockFetch();
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: '/app2/api/v1/export/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ // /app2 should be prefixed because it's not under /app/ - it's a sibling
path
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/app/app2/api/v1/export/',
+ expect.any(Object),
+ );
+});
+
+test('URL prefix guard does not double-prefix URL with query string at app
root', async () => {
+ const appRoot = '/superset';
+ applicationRoot.mockReturnValue(appRoot);
+ makeUrl.mockImplementation((path: string) => `${appRoot}${path}`);
+
+ const mockFetch = createPrefixTestMockFetch();
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: '/superset?foo=1&bar=2',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ // Should NOT double-prefix to /superset/superset?foo=1&bar=2
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/superset?foo=1&bar=2',
+ expect.any(Object),
+ );
+});
+
+test('URL prefix guard does not double-prefix URL with hash at app root',
async () => {
+ const appRoot = '/superset';
+ applicationRoot.mockReturnValue(appRoot);
+ makeUrl.mockImplementation((path: string) => `${appRoot}${path}`);
+
+ const mockFetch = createPrefixTestMockFetch();
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: '/superset#section',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ // Should NOT double-prefix to /superset/superset#section
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/superset#section',
+ expect.any(Object),
+ );
+});
+
+// Streaming export behavior tests
+
+test('sets ERROR status and calls onError when stream contains
__STREAM_ERROR__ marker', async () => {
+ const errorMessage = 'Database connection failed';
+ const errorChunk = new TextEncoder().encode(
+ `__STREAM_ERROR__:${errorMessage}`,
+ );
+
+ let readCount = 0;
+ const mockFetch = jest.fn().mockResolvedValue({
+ ok: true,
+ headers: new Headers({
+ 'Content-Disposition': 'attachment; filename="export.csv"',
+ }),
+ body: {
+ getReader: () => ({
+ read: jest.fn().mockImplementation(() => {
+ readCount += 1;
+ if (readCount === 1) {
+ return Promise.resolve({ done: false, value: errorChunk });
+ }
+ return Promise.resolve({ done: true, value: undefined });
+ }),
+ }),
+ },
+ });
+ global.fetch = mockFetch;
+
+ const onError = jest.fn();
+ const { result } = renderHook(() => useStreamingExport({ onError }));
+
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/sqllab/export_streaming/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.progress.status).toBe(ExportStatus.ERROR);
+ });
+
+ expect(result.current.progress.error).toBe(errorMessage);
+ expect(onError).toHaveBeenCalledTimes(1);
+ expect(onError).toHaveBeenCalledWith(errorMessage);
+});
+
+test('completes CSV export successfully with correct status and downloadUrl',
async () => {
+ applicationRoot.mockReturnValue('');
+ const csvData = new TextEncoder().encode('id,name\n1,Alice\n2,Bob\n');
+
+ let readCount = 0;
+ const mockFetch = jest.fn().mockResolvedValue({
+ ok: true,
+ headers: new Headers({
+ 'Content-Disposition': 'attachment; filename="results.csv"',
+ }),
+ body: {
+ getReader: () => ({
+ read: jest.fn().mockImplementation(() => {
+ readCount += 1;
+ if (readCount === 1) {
+ return Promise.resolve({ done: false, value: csvData });
+ }
+ return Promise.resolve({ done: true, value: undefined });
+ }),
+ }),
+ },
+ });
+ global.fetch = mockFetch;
+
+ const onComplete = jest.fn();
+ const { result } = renderHook(() => useStreamingExport({ onComplete }));
+
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/sqllab/export_streaming/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
+ });
+
+ expect(result.current.progress.downloadUrl).toBe('blob:mock-url');
+ expect(result.current.progress.filename).toBe('results.csv');
+ expect(onComplete).toHaveBeenCalledTimes(1);
+ expect(onComplete).toHaveBeenCalledWith('blob:mock-url', 'results.csv');
+});
+
+test('completes XLSX export successfully with correct filename', async () => {
+ applicationRoot.mockReturnValue('');
+ const xlsxData = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // XLSX magic
bytes
+
+ let readCount = 0;
+ const mockFetch = jest.fn().mockResolvedValue({
+ ok: true,
+ headers: new Headers({
+ 'Content-Disposition': 'attachment; filename="report.xlsx"',
+ }),
+ body: {
+ getReader: () => ({
+ read: jest.fn().mockImplementation(() => {
+ readCount += 1;
+ if (readCount === 1) {
+ return Promise.resolve({ done: false, value: xlsxData });
+ }
+ return Promise.resolve({ done: true, value: undefined });
+ }),
+ }),
+ },
+ });
+ global.fetch = mockFetch;
+
+ const onComplete = jest.fn();
+ const { result } = renderHook(() => useStreamingExport({ onComplete }));
+
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/chart/data',
+ payload: { datasource: '1__table', viz_type: 'table' },
+ exportType: 'xlsx',
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
+ });
+
+ expect(result.current.progress.filename).toBe('report.xlsx');
+ expect(onComplete).toHaveBeenCalledWith('blob:mock-url', 'report.xlsx');
+});
+
+test('sets ERROR status when response is not ok (4xx/5xx)', async () => {
+ applicationRoot.mockReturnValue('');
+ const mockFetch = jest.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ statusText: 'Internal Server Error',
+ headers: new Headers({}),
+ });
+ global.fetch = mockFetch;
+
+ const onError = jest.fn();
+ const { result } = renderHook(() => useStreamingExport({ onError }));
+
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/sqllab/export_streaming/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.progress.status).toBe(ExportStatus.ERROR);
+ });
+
+ expect(result.current.progress.error).toBe(
+ 'Export failed: 500 Internal Server Error',
+ );
+ expect(onError).toHaveBeenCalledWith(
+ 'Export failed: 500 Internal Server Error',
+ );
+});
+
+test('sets ERROR status when response body is missing', async () => {
+ applicationRoot.mockReturnValue('');
+ const mockFetch = jest.fn().mockResolvedValue({
+ ok: true,
+ headers: new Headers({}),
+ body: null,
+ });
+ global.fetch = mockFetch;
+
+ const onError = jest.fn();
+ const { result } = renderHook(() => useStreamingExport({ onError }));
+
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/sqllab/export_streaming/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.progress.status).toBe(ExportStatus.ERROR);
+ });
+
+ expect(result.current.progress.error).toBe(
+ 'Response body is not available for streaming',
+ );
+ expect(onError).toHaveBeenCalledWith(
+ 'Response body is not available for streaming',
+ );
+});
+
+test('cancelExport sets CANCELLED status and aborts the request', async () => {
+ applicationRoot.mockReturnValue('');
+ let abortSignal: AbortSignal | undefined;
+
+ // Create a reader that will hang until aborted
+ const mockFetch = jest.fn().mockImplementation((_url, options) => {
+ abortSignal = options?.signal;
+ return Promise.resolve({
+ ok: true,
+ headers: new Headers({
+ 'Content-Disposition': 'attachment; filename="export.csv"',
+ }),
+ body: {
+ getReader: () => ({
+ read: jest.fn().mockImplementation(
+ () =>
+ new Promise((resolve, reject) => {
+ // Simulate slow stream that can be aborted
+ const timeout = setTimeout(() => {
+ resolve({
+ done: false,
+ value: new TextEncoder().encode('data'),
+ });
+ }, 10000);
+ abortSignal?.addEventListener('abort', () => {
+ clearTimeout(timeout);
+ reject(new Error('Export cancelled by user'));
+ });
+ }),
+ ),
+ }),
+ },
+ });
+ });
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/sqllab/export_streaming/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ // Wait for fetch to be called
+ await waitFor(() => {
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ });
+
+ // Cancel the export
+ act(() => {
+ result.current.cancelExport();
+ });
+
+ await waitFor(() => {
+ expect(result.current.progress.status).toBe(ExportStatus.CANCELLED);
+ });
+});
+
+test('parses filename from Content-Disposition header with quotes', async ()
=> {
+ applicationRoot.mockReturnValue('');
+ const csvData = new TextEncoder().encode('data\n');
+
+ let readCount = 0;
+ const mockFetch = jest.fn().mockResolvedValue({
+ ok: true,
+ headers: new Headers({
+ 'Content-Disposition': 'attachment; filename="my export file.csv"',
+ }),
+ body: {
+ getReader: () => ({
+ read: jest.fn().mockImplementation(() => {
+ readCount += 1;
+ if (readCount === 1) {
+ return Promise.resolve({ done: false, value: csvData });
+ }
+ return Promise.resolve({ done: true, value: undefined });
+ }),
+ }),
+ },
+ });
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/sqllab/export_streaming/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
+ });
+
+ expect(result.current.progress.filename).toBe('my export file.csv');
+});
+
+test('uses default filename when Content-Disposition header is missing', async
() => {
+ applicationRoot.mockReturnValue('');
+ const csvData = new TextEncoder().encode('data\n');
+
+ let readCount = 0;
+ const mockFetch = jest.fn().mockResolvedValue({
+ ok: true,
+ headers: new Headers({}),
+ body: {
+ getReader: () => ({
+ read: jest.fn().mockImplementation(() => {
+ readCount += 1;
+ if (readCount === 1) {
+ return Promise.resolve({ done: false, value: csvData });
+ }
+ return Promise.resolve({ done: true, value: undefined });
+ }),
+ }),
+ },
+ });
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/sqllab/export_streaming/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
+ });
+
+ expect(result.current.progress.filename).toBe('export.csv');
+});
+
+test('updates progress with rowsProcessed and totalSize during streaming',
async () => {
+ applicationRoot.mockReturnValue('');
+ const chunk1 = new TextEncoder().encode('id,name\n1,Alice\n');
+ const chunk2 = new TextEncoder().encode('2,Bob\n3,Charlie\n');
+
+ let readCount = 0;
+ const mockFetch = jest.fn().mockResolvedValue({
+ ok: true,
+ headers: new Headers({
+ 'Content-Disposition': 'attachment; filename="export.csv"',
+ }),
+ body: {
+ getReader: () => ({
+ read: jest.fn().mockImplementation(() => {
+ readCount += 1;
+ if (readCount === 1) {
+ return Promise.resolve({ done: false, value: chunk1 });
+ }
+ if (readCount === 2) {
+ return Promise.resolve({ done: false, value: chunk2 });
+ }
+ return Promise.resolve({ done: true, value: undefined });
+ }),
+ }),
+ },
+ });
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/sqllab/export_streaming/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ expectedRows: 100,
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
+ });
+
+ // Verify final progress reflects data received
+ expect(result.current.progress.totalSize).toBe(chunk1.length +
chunk2.length);
+ // 4 newlines total (2 in chunk1, 2 in chunk2)
+ expect(result.current.progress.rowsProcessed).toBe(4);
+});
+
+test('prevents double startExport calls while export is in progress', async ()
=> {
+ applicationRoot.mockReturnValue('');
+
+ // Create a slow reader that takes time to complete
+ let readCount = 0;
+ const mockFetch = jest.fn().mockResolvedValue({
+ ok: true,
+ headers: new Headers({
+ 'Content-Disposition': 'attachment; filename="export.csv"',
+ }),
+ body: {
+ getReader: () => ({
+ read: jest.fn().mockImplementation(
+ () =>
+ new Promise(resolve => {
+ readCount += 1;
+ if (readCount === 1) {
+ // Delay first chunk to simulate in-progress export
+ setTimeout(() => {
+ resolve({
+ done: false,
+ value: new TextEncoder().encode('data\n'),
+ });
+ }, 50);
+ } else {
+ resolve({ done: true, value: undefined });
+ }
+ }),
+ ),
+ }),
+ },
+ });
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ // Start first export
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/first/',
+ payload: { client_id: 'first' },
+ exportType: 'csv',
+ });
+ });
+
+ // Immediately try to start second export
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/second/',
+ payload: { client_id: 'second' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
+ });
+
+ // Only one fetch call should have been made (first export)
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ expect(mockFetch).toHaveBeenCalledWith('/api/v1/first/', expect.any(Object));
+});
+
+test('retryExport does nothing when no prior export exists', async () => {
+ applicationRoot.mockReturnValue('');
+ const mockFetch = jest.fn();
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ // Call retry without ever calling startExport
+ act(() => {
+ result.current.retryExport();
+ });
+
+ // Give it time to potentially make a call
+ await new Promise(resolve => {
+ setTimeout(resolve, 50);
+ });
+
+ // No fetch should have been made
+ expect(mockFetch).not.toHaveBeenCalled();
+});
+
+test('state resets correctly after successful export and resetExport call',
async () => {
+ applicationRoot.mockReturnValue('');
+ const csvData = new TextEncoder().encode('data\n');
+
+ let readCount = 0;
+ const mockFetch = jest.fn().mockResolvedValue({
+ ok: true,
+ headers: new Headers({
+ 'Content-Disposition': 'attachment; filename="export.csv"',
+ }),
+ body: {
+ getReader: () => ({
+ read: jest.fn().mockImplementation(() => {
+ readCount += 1;
+ if (readCount === 1) {
+ return Promise.resolve({ done: false, value: csvData });
+ }
+ return Promise.resolve({ done: true, value: undefined });
+ }),
+ }),
+ },
+ });
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/sqllab/export_streaming/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
+ });
+
+ // Verify completed state
+ expect(result.current.progress.downloadUrl).toBe('blob:mock-url');
+
+ // Reset the export
+ act(() => {
+ result.current.resetExport();
+ });
+
+ // Verify state is reset
+ expect(result.current.progress.status).toBe(ExportStatus.STREAMING);
+ expect(result.current.progress.rowsProcessed).toBe(0);
+ expect(result.current.progress.totalSize).toBe(0);
+ expect(result.current.progress.downloadUrl).toBeUndefined();
+ expect(result.current.progress.error).toBeUndefined();
+ expect(global.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
+});
+
+test('state resets correctly after failed export and resetExport call', async
() => {
+ applicationRoot.mockReturnValue('');
+ const mockFetch = jest.fn().mockRejectedValue(new Error('Network error'));
+ global.fetch = mockFetch;
+
+ const { result } = renderHook(() => useStreamingExport());
+
+ act(() => {
+ result.current.startExport({
+ url: '/api/v1/sqllab/export_streaming/',
+ payload: { client_id: 'test-id' },
+ exportType: 'csv',
+ });
+ });
+
+ await waitFor(() => {
+ expect(result.current.progress.status).toBe(ExportStatus.ERROR);
+ });
+
+ // Verify error state
+ expect(result.current.progress.error).toBe('Network error');
+
+ // Reset the export
+ act(() => {
+ result.current.resetExport();
+ });
+
+ // Verify state is reset
+ expect(result.current.progress.status).toBe(ExportStatus.STREAMING);
+ expect(result.current.progress.error).toBeUndefined();
+ expect(result.current.progress.rowsProcessed).toBe(0);
+});
diff --git
a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts
b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts
index fd0bf34cb2..05a7e5d5b7 100644
---
a/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts
+++
b/superset-frontend/src/components/StreamingExportModal/useStreamingExport.ts
@@ -19,6 +19,8 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { SupersetClient } from '@superset-ui/core';
import { ExportStatus, StreamingProgress } from './StreamingExportModal';
+import { makeUrl } from 'src/utils/pathUtils';
+import { applicationRoot } from 'src/utils/getBootstrapData';
interface UseStreamingExportOptions {
onComplete?: (downloadUrl: string, filename: string) => void;
@@ -30,6 +32,16 @@ interface StreamingExportPayload {
}
interface StreamingExportParams {
+ /**
+ * The API endpoint URL for the export request.
+ *
+ * URLs should be prefixed with the application root at the call site using
+ * `makeUrl()` from 'src/utils/pathUtils'. This ensures proper handling for
+ * subdirectory deployments (e.g., /superset/api/v1/...).
+ *
+ * A defensive guard (`ensureUrlPrefix`) will apply the prefix if missing,
+ * but callers should not rely on this fallback behavior.
+ */
url: string;
payload: StreamingExportPayload;
filename?: string;
@@ -39,6 +51,45 @@ interface StreamingExportParams {
const NEWLINE_BYTE = 10; // '\n' character code
+/**
+ * Ensures URL has the application root prefix for subdirectory deployments.
+ * Applies makeUrl to relative paths that don't already include the app root.
+ * This guards against callers forgetting to prefix URLs when using native
fetch.
+ */
+const ensureUrlPrefix = (url: string): string => {
+ const appRoot = applicationRoot();
+ // Protocol-relative URLs (//example.com/...) should pass through unchanged
+ if (url.startsWith('//')) {
+ return url;
+ }
+ // Absolute URLs (http:// or https://) should pass through unchanged
+ if (url.match(/^https?:\/\//)) {
+ return url;
+ }
+ // Relative URLs without leading slash (e.g., "api/v1/...") need
normalization
+ // Add leading slash and apply prefix
+ if (!url.startsWith('/')) {
+ return makeUrl(`/${url}`);
+ }
+ // If no app root configured, return as-is
+ if (!appRoot) {
+ return url;
+ }
+ // If URL already has the app root prefix, return as-is
+ // Use strict check to avoid false positives with sibling paths (e.g., /app2
when appRoot is /app)
+ // Also handle query strings and hashes (e.g., /superset?foo=1 or
/superset#hash)
+ if (
+ url === appRoot ||
+ url.startsWith(`${appRoot}/`) ||
+ url.startsWith(`${appRoot}?`) ||
+ url.startsWith(`${appRoot}#`)
+ ) {
+ return url;
+ }
+ // Apply prefix via makeUrl
+ return makeUrl(url);
+};
+
const createFetchRequest = async (
_url: string,
payload: StreamingExportPayload,
@@ -63,7 +114,7 @@ const createFetchRequest = async (
formParams.filename = filename;
}
- if (expectedRows) {
+ if (expectedRows !== undefined) {
formParams.expected_rows = expectedRows.toString();
}
@@ -157,7 +208,9 @@ export const useStreamingExport = (options:
UseStreamingExportOptions = {}) => {
expectedRows,
abortControllerRef.current.signal,
);
- const response = await fetch(url, fetchOptions);
+ // Guard: ensure URL has app root prefix for subdirectory deployments
+ const prefixedUrl = ensureUrlPrefix(url);
+ const response = await fetch(prefixedUrl, fetchOptions);
if (!response.ok) {
throw new Error(
diff --git a/superset-frontend/src/explore/exploreUtils/exportChart.test.ts
b/superset-frontend/src/explore/exploreUtils/exportChart.test.ts
new file mode 100644
index 0000000000..661e1248b3
--- /dev/null
+++ b/superset-frontend/src/explore/exploreUtils/exportChart.test.ts
@@ -0,0 +1,173 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { exportChart } from '.';
+
+// Mock pathUtils to control app root prefix
+jest.mock('src/utils/pathUtils', () => ({
+ ensureAppRoot: jest.fn((path: string) => path),
+}));
+
+// Mock SupersetClient
+jest.mock('@superset-ui/core', () => ({
+ ...jest.requireActual('@superset-ui/core'),
+ SupersetClient: {
+ postForm: jest.fn(),
+ get: jest.fn().mockResolvedValue({ json: {} }),
+ post: jest.fn().mockResolvedValue({ json: {} }),
+ },
+ getChartBuildQueryRegistry: jest.fn().mockReturnValue({
+ get: jest.fn().mockReturnValue(() => () => ({})),
+ }),
+ getChartMetadataRegistry: jest.fn().mockReturnValue({
+ get: jest.fn().mockReturnValue({ parseMethod: 'json' }),
+ }),
+}));
+
+const { ensureAppRoot } = jest.requireMock('src/utils/pathUtils');
+const { getChartMetadataRegistry } = jest.requireMock('@superset-ui/core');
+
+// Minimal formData that won't trigger legacy API (useLegacyApi = false)
+const baseFormData = {
+ datasource: '1__table',
+ viz_type: 'table',
+};
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ // Default: no prefix
+ ensureAppRoot.mockImplementation((path: string) => path);
+ // Default: v1 API (not legacy)
+ getChartMetadataRegistry.mockReturnValue({
+ get: jest.fn().mockReturnValue({ parseMethod: 'json' }),
+ });
+});
+
+// Tests for exportChart URL prefix handling in streaming export
+test('exportChart v1 API passes prefixed URL to onStartStreamingExport when
app root is configured', async () => {
+ const appRoot = '/superset';
+ ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`);
+
+ const onStartStreamingExport = jest.fn();
+
+ await exportChart({
+ formData: baseFormData,
+ resultFormat: 'csv',
+ onStartStreamingExport: onStartStreamingExport as unknown as null,
+ });
+
+ expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
+ const callArgs = onStartStreamingExport.mock.calls[0][0];
+ expect(callArgs.url).toBe('/superset/api/v1/chart/data');
+ expect(callArgs.exportType).toBe('csv');
+});
+
+test('exportChart v1 API passes unprefixed URL when no app root is
configured', async () => {
+ ensureAppRoot.mockImplementation((path: string) => path);
+
+ const onStartStreamingExport = jest.fn();
+
+ await exportChart({
+ formData: baseFormData,
+ resultFormat: 'csv',
+ onStartStreamingExport: onStartStreamingExport as unknown as null,
+ });
+
+ expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
+ const callArgs = onStartStreamingExport.mock.calls[0][0];
+ expect(callArgs.url).toBe('/api/v1/chart/data');
+});
+
+test('exportChart v1 API passes nested prefix for deeply nested deployments',
async () => {
+ const appRoot = '/my-company/analytics/superset';
+ ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`);
+
+ const onStartStreamingExport = jest.fn();
+
+ await exportChart({
+ formData: baseFormData,
+ resultFormat: 'xlsx',
+ onStartStreamingExport: onStartStreamingExport as unknown as null,
+ });
+
+ expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
+ const callArgs = onStartStreamingExport.mock.calls[0][0];
+
expect(callArgs.url).toBe('/my-company/analytics/superset/api/v1/chart/data');
+ expect(callArgs.exportType).toBe('xlsx');
+});
+
+test('exportChart passes csv exportType for CSV exports', async () => {
+ const onStartStreamingExport = jest.fn();
+
+ await exportChart({
+ formData: baseFormData,
+ resultFormat: 'csv',
+ onStartStreamingExport: onStartStreamingExport as unknown as null,
+ });
+
+ expect(onStartStreamingExport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ exportType: 'csv',
+ }),
+ );
+});
+
+test('exportChart passes xlsx exportType for Excel exports', async () => {
+ const onStartStreamingExport = jest.fn();
+
+ await exportChart({
+ formData: baseFormData,
+ resultFormat: 'xlsx',
+ onStartStreamingExport: onStartStreamingExport as unknown as null,
+ });
+
+ expect(onStartStreamingExport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ exportType: 'xlsx',
+ }),
+ );
+});
+
+test('exportChart legacy API (useLegacyApi=true) passes prefixed URL with app
root configured', async () => {
+ // Legacy API uses getExploreUrl() -> getURIDirectory() -> ensureAppRoot()
+ const appRoot = '/superset';
+ ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`);
+
+ // Configure mock to return useLegacyApi: true
+ getChartMetadataRegistry.mockReturnValue({
+ get: jest.fn().mockReturnValue({ useLegacyApi: true, parseMethod: 'json'
}),
+ });
+
+ const onStartStreamingExport = jest.fn();
+ const legacyFormData = {
+ datasource: '1__table',
+ viz_type: 'legacy_viz',
+ };
+
+ await exportChart({
+ formData: legacyFormData,
+ resultFormat: 'csv',
+ onStartStreamingExport: onStartStreamingExport as unknown as null,
+ });
+
+ expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
+ const callArgs = onStartStreamingExport.mock.calls[0][0];
+ // Legacy path uses getURIDirectory which calls ensureAppRoot
+ expect(callArgs.url).toContain(appRoot);
+ expect(callArgs.exportType).toBe('csv');
+});
diff --git a/superset-frontend/src/utils/export.test.ts
b/superset-frontend/src/utils/export.test.ts
index 9f590005bf..1a0f96be71 100644
--- a/superset-frontend/src/utils/export.test.ts
+++ b/superset-frontend/src/utils/export.test.ts
@@ -37,6 +37,7 @@ jest.mock('@apache-superset/core', () => ({
jest.mock('content-disposition');
+// Default no-op mock for pathUtils; specific tests customize ensureAppRoot to
simulate app root prefixing
jest.mock('./pathUtils', () => ({
ensureAppRoot: jest.fn((path: string) => path),
}));
@@ -400,3 +401,53 @@ test('handles export with empty IDs array', async () => {
}),
);
});
+
+const { ensureAppRoot } = jest.requireMock('./pathUtils');
+
+const doublePrefixTestCases = [
+ {
+ name: 'subdirectory prefix',
+ appRoot: '/superset',
+ resource: 'dashboard',
+ ids: [1],
+ },
+ {
+ name: 'subdirectory prefix (dataset)',
+ appRoot: '/superset',
+ resource: 'dataset',
+ ids: [1],
+ },
+ {
+ name: 'nested prefix',
+ appRoot: '/my-app/superset',
+ resource: 'dataset',
+ ids: [1, 2],
+ },
+];
+
+test.each(doublePrefixTestCases)(
+ 'handleResourceExport endpoint should not include app prefix: $name',
+ async ({ appRoot, resource, ids }) => {
+ // Simulate real ensureAppRoot behavior: prepend the appRoot
+ (ensureAppRoot as jest.Mock).mockImplementation(
+ (path: string) => `${appRoot}${path}`,
+ );
+
+ const doneMock = jest.fn();
+ await handleResourceExport(resource, ids, doneMock);
+
+ // The endpoint passed to SupersetClient.get should NOT have the appRoot
prefix
+ // because SupersetClient.getUrl() adds it when building the full URL.
+ const expectedEndpoint =
`/api/v1/${resource}/export/?q=!(${ids.join(',')})`;
+
+ // Explicitly verify no prefix in endpoint - this will fail if
ensureAppRoot is used
+ const callArgs = (SupersetClient.get as jest.Mock).mock.calls.slice(
+ -1,
+ )[0][0];
+ expect(callArgs.endpoint).not.toContain(appRoot);
+ expect(callArgs.endpoint).toBe(expectedEndpoint);
+
+ // Reset mock for next test
+ (ensureAppRoot as jest.Mock).mockImplementation((path: string) => path);
+ },
+);
diff --git a/superset-frontend/src/utils/export.ts
b/superset-frontend/src/utils/export.ts
index e26ab29921..cabbc49372 100644
--- a/superset-frontend/src/utils/export.ts
+++ b/superset-frontend/src/utils/export.ts
@@ -20,7 +20,6 @@ import { SupersetClient } from '@superset-ui/core';
import { logging } from '@apache-superset/core';
import rison from 'rison';
import contentDisposition from 'content-disposition';
-import { ensureAppRoot } from './pathUtils';
// Maximum blob size for in-memory downloads (100MB)
const MAX_BLOB_SIZE = 100 * 1024 * 1024;
@@ -50,9 +49,7 @@ export default async function handleResourceExport(
ids: number[],
done: () => void,
): Promise<void> {
- const endpoint = ensureAppRoot(
- `/api/v1/${resource}/export/?q=${rison.encode(ids)}`,
- );
+ const endpoint = `/api/v1/${resource}/export/?q=${rison.encode(ids)}`;
try {
// Use fetch with blob response instead of iframe to avoid CSP frame-src
violations