Compare commits

...

16 Commits

Author SHA1 Message Date
Elizabeth Thompson
7186f92d38 test: add comprehensive unhappy path tests for export
Add tests for error scenarios including:
- Network errors and logging
- 404 errors when resource not found
- Empty response handling
- Download failure with proper cleanup
- Malformed Content-Disposition header
- Missing headers fallback
- Empty IDs array edge case

These tests ensure proper error handling and cleanup in all failure modes.
2025-10-17 11:47:04 -07:00
Elizabeth Thompson
513fc30bb5 style: fix prettier formatting and add eslint-disable comment
- Auto-format ChartTable.test.tsx with prettier
- Add eslint-disable comment for jest-dom/prefer-to-have-style in export.test.ts
  since toHaveStyle is not available without React Testing Library
2025-10-09 17:41:38 -07:00
Elizabeth Thompson
4a94a4c387 test: use direct style property check in export test
Replace toHaveStyle() with direct style.display check since this test
doesn't use React Testing Library and toHaveStyle is not available.
2025-10-09 17:27:50 -07:00
Elizabeth Thompson
8a6461816a style: fix prettier formatting in ChartTable test 2025-10-09 17:26:46 -07:00
Elizabeth Thompson
0b251d80fa test: move jest.mock to module level in ChartTable test
Move the export mock from inside the test body to module level to match
the correct pattern used in DashboardTable.test.tsx. This ensures the
mock is properly hoisted and consistently applied.

Also add verification that export is called with correct chart ID.

Addresses code review feedback from @joe in PR #35584.
2025-10-09 17:26:24 -07:00
Elizabeth Thompson
efe10ecebe style: fix linting errors in test files
- Fix import order in DashboardTable.test.tsx
- Remove unnecessary block statement in ChartTable.test.tsx
- Use toHaveStyle matcher in export.test.ts
2025-10-09 17:11:14 -07:00
Elizabeth Thompson
6c11c49524 test: fix DashboardTable export test with proper mock
- Mock export function at module level instead of inside test
- Add delay to mock implementation to ensure spinner appears
- Use waitFor to check for spinner appearance asynchronously
- Verify export is called with correct dashboard ID
2025-10-09 16:51:47 -07:00
Elizabeth Thompson
9afd7609a5 test: fix export test assertions
- Replace toHaveStyle() with direct style.display check in export.test.ts
- Add can_export permission to ChartTable test mock to enable Export menu option
2025-10-09 15:46:11 -07:00
Elizabeth Thompson
18a325a4eb test: fix revokeObjectURL mock in export tests
Add conditional creation of window.URL.revokeObjectURL in test setup
since it doesn't exist in Jest's JSDOM environment. This prevents
'Property revokeObjectURL does not exist' errors during test execution.
2025-10-09 15:28:27 -07:00
Elizabeth Thompson
c1f9541640 style: fix prettier and jest-dom linting errors in export test
- Fix line breaks in spyOn calls to match prettier formatting
- Replace direct style assertion with toHaveStyle matcher
- Improves test readability and follows jest-dom best practices

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 13:39:05 -07:00
Elizabeth Thompson
22afa0448f test: add comprehensive export tests for ChartTable and DashboardTable
Add integration tests that verify:
- Export button triggers the export flow
- Loading spinner appears during export operation
- Spinner disappears after export completes
- Export functionality works end-to-end in table views

These tests fill a testing gap by ensuring the export button
interaction and loading states work correctly in the home page tables.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 13:37:21 -07:00
Elizabeth Thompson
da61031603 fix: remove jest.requireMock to resolve eslint error
Replace dynamic require with static import of logging from @superset-ui/core
to fix global-require and @typescript-eslint/no-var-requires linting errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 11:58:17 -07:00
Elizabeth Thompson
252f306a30 refactor(export): address korbit-ai code review suggestions
Implement improvements suggested by korbit-ai code review:

1. Extract download logic into separate `downloadBlob()` utility function
   - Improves code reusability and testability
   - Better separation of concerns

2. Add memory safety check for large exports
   - Warn when Content-Length exceeds 100MB limit
   - Helps prevent browser memory issues with large files
   - Note: We cannot fallback to window.location.href as suggested,
     since that would reintroduce the CSP violation this PR fixes

3. Optimize DOM manipulation to prevent layout thrashing
   - Use display: 'none' on anchor element
   - Avoids triggering unnecessary layout calculations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 11:11:05 -07:00
Elizabeth Thompson
c9003b6697 style: fix eslint and prettier violations in export tests
Fix linting errors in export test file and DashboardList:
- Remove 'describe' block (use test() instead per conventions)
- Fix prettier formatting issues
- Replace require() with import statements
- Remove await-in-loop by testing sequentially
- Fix multi-line function parameter formatting

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 10:59:42 -07:00
Elizabeth Thompson
4d5c78953c test(export): add comprehensive tests for blob-based export functionality
Add unit tests for the new fetch-based export utility to ensure:
- Correct API endpoint construction and headers
- Proper blob download and DOM manipulation
- Content-Disposition header parsing with fallbacks
- Error handling for API and blob conversion failures
- Cleanup of blob URLs and DOM elements
- Support for multiple resource types and IDs

Tests cover success paths, error cases, and edge cases like
malformed headers and missing Content-Disposition.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 10:38:13 -07:00
Elizabeth Thompson
001cccac54 fix(export): replace iframe with fetch to avoid CSP frame-src violations
Replace iframe-based resource export with fetch API and blob downloads
to prevent Content Security Policy frame-src violations.

Changes:
- Update handleResourceExport() to use SupersetClient.get() with blob response
- Parse Content-Disposition headers for proper filenames
- Implement programmatic download using blob URLs
- Add proper async/await error handling in all export handlers
- Add user-friendly error messages on export failures

This approach:
- Eliminates CSP frame-src violations
- Provides better error handling and user feedback
- Follows the same pattern as dashboard screenshot downloads
- Works with strict Content Security Policies

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 10:35:58 -07:00
12 changed files with 659 additions and 55 deletions

View File

@@ -25,8 +25,19 @@ import {
import { VizType } from '@superset-ui/core';
import fetchMock from 'fetch-mock';
import { act } from 'react-dom/test-utils';
import handleResourceExport from 'src/utils/export';
import ChartTable from './ChartTable';
// Mock the export module
jest.mock('src/utils/export', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockExport = handleResourceExport as jest.MockedFunction<
typeof handleResourceExport
>;
const chartsEndpoint = 'glob:*/api/v1/chart/?*';
const chartsInfoEndpoint = 'glob:*/api/v1/chart/_info*';
const chartFavoriteStatusEndpoint = 'glob:*/api/v1/chart/favorite_status*';
@@ -47,7 +58,7 @@ fetchMock.get(chartsEndpoint, {
});
fetchMock.get(chartsInfoEndpoint, {
permissions: ['can_add', 'can_edit', 'can_delete'],
permissions: ['can_add', 'can_edit', 'can_delete', 'can_export'],
});
fetchMock.get(chartFavoriteStatusEndpoint, {
@@ -115,3 +126,53 @@ test('renders mine tab on click', async () => {
expect(screen.getAllByText(/cool chart/i)).toHaveLength(3);
});
});
test('handles chart export with correct ID and shows spinner', async () => {
// Mock export to take some time before calling the done callback
mockExport.mockImplementation(
(resource: string, ids: number[], done: () => void) =>
new Promise(resolve => {
setTimeout(() => {
done();
resolve();
}, 100);
}),
);
await renderChartTable(mineTabProps);
// Click Mine tab to see charts
userEvent.click(screen.getByText(/mine/i));
await waitFor(() => {
expect(screen.getAllByText(/cool chart/i)).toHaveLength(3);
});
// Find and click the more options button for the first chart
const moreButtons = screen.getAllByRole('img', { name: /more/i });
await userEvent.click(moreButtons[0]);
// Wait for dropdown menu
await waitFor(() => {
expect(screen.getByText('Export')).toBeInTheDocument();
});
const exportOption = screen.getByText('Export');
await userEvent.click(exportOption);
// Verify spinner appears during export
await waitFor(() => {
expect(screen.getByRole('status')).toBeInTheDocument();
});
// Verify the export was called with correct parameters
expect(mockExport).toHaveBeenCalledWith('chart', [0], expect.any(Function));
// Wait for export to complete and spinner to disappear
await waitFor(
() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
},
{ timeout: 3000 },
);
});

View File

@@ -132,12 +132,17 @@ function ChartTable({
setLoaded(true);
}, [activeTab]);
const handleBulkChartExport = (chartsToExport: Chart[]) => {
const handleBulkChartExport = async (chartsToExport: Chart[]) => {
const ids = chartsToExport.map(({ id }) => id);
handleResourceExport('chart', ids, () => {
setPreparingExport(false);
});
setPreparingExport(true);
try {
await handleResourceExport('chart', ids, () => {
setPreparingExport(false);
});
} catch (error) {
setPreparingExport(false);
addDangerToast(t('There was an issue exporting the selected charts'));
}
};
const menuTabs = [

View File

@@ -28,8 +28,19 @@ import { Router } from 'react-router-dom';
import { configureStore } from '@reduxjs/toolkit';
import fetchMock from 'fetch-mock';
import * as hooks from 'src/views/CRUD/hooks';
import handleResourceExport from 'src/utils/export';
import DashboardTable from './DashboardTable';
// Mock the export module
jest.mock('src/utils/export', () => ({
__esModule: true,
default: jest.fn(),
}));
const mockExport = handleResourceExport as jest.MockedFunction<
typeof handleResourceExport
>;
jest.mock('src/views/CRUD/utils', () => ({
...jest.requireActual('src/views/CRUD/utils'),
handleDashboardDelete: jest
@@ -254,12 +265,38 @@ describe('DashboardTable', () => {
expect(otherTab).toHaveClass('active');
});
test('handles bulk dashboard export', async () => {
test('handles bulk dashboard export with correct ID and shows spinner', async () => {
// Mock export to take some time before calling the done callback
mockExport.mockImplementation(
(resource: string, ids: number[], done: () => void) =>
new Promise(resolve => {
setTimeout(() => {
done();
resolve();
}, 100);
}),
);
const props = {
...defaultProps,
mine: mockDashboards,
};
jest.spyOn(hooks, 'useListViewResource').mockImplementation(() => ({
state: {
loading: false,
resourceCollection: mockDashboards,
resourceCount: mockDashboards.length,
bulkSelectEnabled: false,
lastFetched: new Date().toISOString(),
},
setResourceCollection: jest.fn(),
hasPerm: jest.fn().mockReturnValue(true),
refreshData: jest.fn(),
fetchData: jest.fn(),
toggleBulkSelect: jest.fn(),
}));
render(
<Router history={history}>
<DashboardTable {...props} />
@@ -280,7 +317,25 @@ describe('DashboardTable', () => {
const exportOption = screen.getByText('Export');
await userEvent.click(exportOption);
expect(screen.getByRole('status')).toBeInTheDocument();
// Verify spinner shows up during export
await waitFor(() => {
expect(screen.getByRole('status')).toBeInTheDocument();
});
// Verify the export was called with correct parameters
expect(mockExport).toHaveBeenCalledWith(
'dashboard',
[1],
expect.any(Function),
);
// Wait for export to complete and spinner to disappear
await waitFor(
() => {
expect(screen.queryByRole('status')).not.toBeInTheDocument();
},
{ timeout: 3000 },
);
});
test('handles dashboard deletion confirmation', async () => {

View File

@@ -118,12 +118,17 @@ function DashboardTable({
setLoaded(true);
}, [activeTab]);
const handleBulkDashboardExport = (dashboardsToExport: Dashboard[]) => {
const handleBulkDashboardExport = async (dashboardsToExport: Dashboard[]) => {
const ids = dashboardsToExport.map(({ id }) => id);
handleResourceExport('dashboard', ids, () => {
setPreparingExport(false);
});
setPreparingExport(true);
try {
await handleResourceExport('dashboard', ids, () => {
setPreparingExport(false);
});
} catch (error) {
setPreparingExport(false);
addDangerToast(t('There was an issue exporting the selected dashboards'));
}
};
const handleDashboardEdit = (edits: Dashboard) =>

View File

@@ -241,12 +241,17 @@ function ChartList(props: ChartListProps) {
const canDelete = hasPerm('can_write');
const canExport = hasPerm('can_export');
const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
const handleBulkChartExport = (chartsToExport: Chart[]) => {
const handleBulkChartExport = async (chartsToExport: Chart[]) => {
const ids = chartsToExport.map(({ id }) => id);
handleResourceExport('chart', ids, () => {
setPreparingExport(false);
});
setPreparingExport(true);
try {
await handleResourceExport('chart', ids, () => {
setPreparingExport(false);
});
} catch (error) {
setPreparingExport(false);
addDangerToast(t('There was an issue exporting the selected charts'));
}
};
function handleBulkChartDelete(chartsToDelete: Chart[]) {

View File

@@ -274,12 +274,17 @@ function DashboardList(props: DashboardListProps) {
);
}
const handleBulkDashboardExport = (dashboardsToExport: Dashboard[]) => {
const handleBulkDashboardExport = async (dashboardsToExport: Dashboard[]) => {
const ids = dashboardsToExport.map(({ id }) => id);
handleResourceExport('dashboard', ids, () => {
setPreparingExport(false);
});
setPreparingExport(true);
try {
await handleResourceExport('dashboard', ids, () => {
setPreparingExport(false);
});
} catch (error) {
setPreparingExport(false);
addDangerToast(t('There was an issue exporting the selected dashboards'));
}
};
function handleBulkDashboardDelete(dashboardsToDelete: Dashboard[]) {

View File

@@ -331,15 +331,20 @@ function DatabaseList({
];
}
function handleDatabaseExport(database: DatabaseObject) {
async function handleDatabaseExport(database: DatabaseObject) {
if (database.id === undefined) {
return;
}
handleResourceExport('database', [database.id], () => {
setPreparingExport(false);
});
setPreparingExport(true);
try {
await handleResourceExport('database', [database.id], () => {
setPreparingExport(false);
});
} catch (error) {
setPreparingExport(false);
addDangerToast(t('There was an issue exporting the database'));
}
}
function handleDatabasePermSync(database: DatabaseObject) {

View File

@@ -275,12 +275,17 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
setDatasetCurrentlyDuplicating(dataset);
};
const handleBulkDatasetExport = (datasetsToExport: Dataset[]) => {
const handleBulkDatasetExport = async (datasetsToExport: Dataset[]) => {
const ids = datasetsToExport.map(({ id }) => id);
handleResourceExport('dataset', ids, () => {
setPreparingExport(false);
});
setPreparingExport(true);
try {
await handleResourceExport('dataset', ids, () => {
setPreparingExport(false);
});
} catch (error) {
setPreparingExport(false);
addDangerToast(t('There was an issue exporting the selected datasets'));
}
};
const columns = useMemo(

View File

@@ -291,14 +291,19 @@ function SavedQueryList({
);
};
const handleBulkSavedQueryExport = (
const handleBulkSavedQueryExport = async (
savedQueriesToExport: SavedQueryObject[],
) => {
const ids = savedQueriesToExport.map(({ id }) => id);
handleResourceExport('saved_query', ids, () => {
setPreparingExport(false);
});
setPreparingExport(true);
try {
await handleResourceExport('saved_query', ids, () => {
setPreparingExport(false);
});
} catch (error) {
setPreparingExport(false);
addDangerToast(t('There was an issue exporting the selected queries'));
}
};
const handleBulkQueryDelete = (queriesToDelete: SavedQueryObject[]) => {

View File

@@ -221,14 +221,19 @@ function ThemesList({
setAppliedThemeId(null);
}
const handleBulkThemeExport = (themesToExport: ThemeObject[]) => {
const handleBulkThemeExport = async (themesToExport: ThemeObject[]) => {
const ids = themesToExport
.map(({ id }) => id)
.filter((id): id is number => id !== undefined);
handleResourceExport('theme', ids, () => {
setPreparingExport(false);
});
setPreparingExport(true);
try {
await handleResourceExport('theme', ids, () => {
setPreparingExport(false);
});
} catch (error) {
setPreparingExport(false);
addDangerToast(t('There was an issue exporting the selected themes'));
}
};
const openThemeImportModal = () => {

View File

@@ -0,0 +1,398 @@
/**
* 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 { SupersetClient, logging } from '@superset-ui/core';
import contentDisposition from 'content-disposition';
import handleResourceExport from './export';
// Mock dependencies
jest.mock('@superset-ui/core', () => ({
SupersetClient: {
get: jest.fn(),
},
logging: {
warn: jest.fn(),
error: jest.fn(),
},
}));
jest.mock('content-disposition');
jest.mock('./pathUtils', () => ({
ensureAppRoot: jest.fn((path: string) => path),
}));
let mockBlob: Blob;
let mockResponse: Response;
let createElementSpy: jest.SpyInstance;
let createObjectURLSpy: jest.SpyInstance;
let revokeObjectURLSpy: jest.SpyInstance;
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Mock Blob
mockBlob = new Blob(['test data'], { type: 'application/zip' });
// Mock Response with Headers
mockResponse = {
headers: new Headers({
'Content-Disposition': 'attachment; filename="dashboard_export.zip"',
}),
blob: jest.fn().mockResolvedValue(mockBlob),
} as unknown as Response;
// Mock SupersetClient.get
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
// Mock DOM APIs
const mockAnchor = document.createElement('a');
mockAnchor.click = jest.fn();
createElementSpy = jest
.spyOn(document, 'createElement')
.mockReturnValue(mockAnchor);
jest.spyOn(document.body, 'appendChild').mockImplementation(() => mockAnchor);
jest.spyOn(document.body, 'removeChild').mockImplementation(() => mockAnchor);
// Mock URL.createObjectURL and revokeObjectURL
createObjectURLSpy = jest
.spyOn(window.URL, 'createObjectURL')
.mockReturnValue('blob:mock-url');
// Create revokeObjectURL if it doesn't exist
if (!window.URL.revokeObjectURL) {
window.URL.revokeObjectURL = jest.fn();
}
revokeObjectURLSpy = jest.spyOn(window.URL, 'revokeObjectURL');
});
afterEach(() => {
createElementSpy.mockRestore();
createObjectURLSpy.mockRestore();
if (revokeObjectURLSpy) {
revokeObjectURLSpy.mockRestore();
}
});
test('exports resource with correct endpoint and headers', async () => {
const doneMock = jest.fn();
await handleResourceExport('dashboard', [1, 2, 3], doneMock);
expect(SupersetClient.get).toHaveBeenCalledWith({
endpoint: '/api/v1/dashboard/export/?q=!(1,2,3)',
headers: {
Accept: 'application/zip, application/x-zip-compressed, text/plain',
},
parseMethod: 'raw',
});
});
test('creates blob and triggers download', async () => {
const doneMock = jest.fn();
await handleResourceExport('dashboard', [1], doneMock);
// Check that blob was created
expect(mockResponse.blob).toHaveBeenCalled();
// Check that object URL was created
expect(window.URL.createObjectURL).toHaveBeenCalledWith(mockBlob);
// Check that anchor element was created and configured
expect(document.createElement).toHaveBeenCalledWith('a');
const anchor = document.createElement('a');
expect(anchor.href).toBe('blob:mock-url');
expect(anchor.download).toBe('dashboard_export.zip');
// eslint-disable-next-line jest-dom/prefer-to-have-style -- toHaveStyle not available without React Testing Library
expect(anchor.style.display).toBe('none');
// Check that click was triggered
expect(anchor.click).toHaveBeenCalled();
// Check cleanup
expect(document.body.removeChild).toHaveBeenCalled();
expect(window.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
});
test('calls done callback on success', async () => {
const doneMock = jest.fn();
await handleResourceExport('dashboard', [1], doneMock);
expect(doneMock).toHaveBeenCalled();
});
test('uses default filename when Content-Disposition is missing', async () => {
mockResponse = {
headers: new Headers(),
blob: jest.fn().mockResolvedValue(mockBlob),
} as unknown as Response;
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
const doneMock = jest.fn();
await handleResourceExport('chart', [42], doneMock);
const anchor = document.createElement('a');
expect(anchor.download).toBe('chart_export.zip');
});
test('handles Content-Disposition parsing errors gracefully', async () => {
(contentDisposition.parse as jest.Mock).mockImplementationOnce(() => {
throw new Error('Invalid header');
});
const doneMock = jest.fn();
await handleResourceExport('dashboard', [1], doneMock);
// Should fall back to default filename
const anchor = document.createElement('a');
expect(anchor.download).toBe('dashboard_export.zip');
expect(doneMock).toHaveBeenCalled();
});
test('handles API errors and calls done callback', async () => {
const apiError = new Error('API Error');
(SupersetClient.get as jest.Mock).mockRejectedValue(apiError);
const doneMock = jest.fn();
await expect(
handleResourceExport('dashboard', [1], doneMock),
).rejects.toThrow('API Error');
expect(doneMock).toHaveBeenCalled();
});
test('handles blob conversion errors', async () => {
const blobError = new Error('Blob conversion failed');
mockResponse.blob = jest.fn().mockRejectedValue(blobError);
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
const doneMock = jest.fn();
await expect(
handleResourceExport('dashboard', [1], doneMock),
).rejects.toThrow('Blob conversion failed');
expect(doneMock).toHaveBeenCalled();
});
test('exports multiple resources with correct IDs', async () => {
const doneMock = jest.fn();
await handleResourceExport('dataset', [10, 20, 30, 40], doneMock);
expect(SupersetClient.get).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/v1/dataset/export/?q=!(10,20,30,40)',
}),
);
});
test('parses filename from Content-Disposition with quotes', async () => {
(contentDisposition.parse as jest.Mock).mockReturnValueOnce({
type: 'attachment',
parameters: { filename: 'my_custom_export.zip' },
});
const doneMock = jest.fn();
await handleResourceExport('dashboard', [1], doneMock);
const anchor = document.createElement('a');
expect(anchor.download).toBe('my_custom_export.zip');
});
test('warns when export exceeds maximum blob size', async () => {
const largeFileSize = 150 * 1024 * 1024; // 150MB
mockResponse = {
headers: new Headers({
'Content-Length': largeFileSize.toString(),
'Content-Disposition': 'attachment; filename="large_export.zip"',
}),
blob: jest.fn().mockResolvedValue(mockBlob),
} as unknown as Response;
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
const doneMock = jest.fn();
await handleResourceExport('dashboard', [1], doneMock);
expect(logging.warn).toHaveBeenCalledWith(
expect.stringContaining('exceeds maximum blob size'),
);
expect(doneMock).toHaveBeenCalled();
});
test('handles various resource types', async () => {
const doneMock = jest.fn();
await handleResourceExport('dashboard', [1], doneMock);
expect(SupersetClient.get).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/v1/dashboard/export/?q=!(1)',
}),
);
await handleResourceExport('chart', [1], doneMock);
expect(SupersetClient.get).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/v1/chart/export/?q=!(1)',
}),
);
await handleResourceExport('dataset', [1], doneMock);
expect(SupersetClient.get).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/v1/dataset/export/?q=!(1)',
}),
);
await handleResourceExport('database', [1], doneMock);
expect(SupersetClient.get).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/v1/database/export/?q=!(1)',
}),
);
await handleResourceExport('query', [1], doneMock);
expect(SupersetClient.get).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/v1/query/export/?q=!(1)',
}),
);
expect(doneMock).toHaveBeenCalledTimes(5);
});
test('handles network errors and logs them', async () => {
const networkError = new Error('Network request failed');
(SupersetClient.get as jest.Mock).mockRejectedValue(networkError);
const doneMock = jest.fn();
await expect(
handleResourceExport('dashboard', [1], doneMock),
).rejects.toThrow('Network request failed');
expect(logging.error).toHaveBeenCalledWith(
'Resource export failed:',
networkError,
);
expect(doneMock).toHaveBeenCalled();
});
test('handles 404 errors when resource not found', async () => {
const notFoundError = new Error('Not found');
(SupersetClient.get as jest.Mock).mockRejectedValue(notFoundError);
const doneMock = jest.fn();
await expect(
handleResourceExport('dashboard', [999], doneMock),
).rejects.toThrow('Not found');
expect(doneMock).toHaveBeenCalled();
});
test('handles empty response from server', async () => {
const emptyBlob = new Blob([], { type: 'application/zip' });
mockResponse = {
headers: new Headers({
'Content-Disposition': 'attachment; filename="empty.zip"',
}),
blob: jest.fn().mockResolvedValue(emptyBlob),
} as unknown as Response;
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
const doneMock = jest.fn();
await handleResourceExport('dashboard', [1], doneMock);
expect(window.URL.createObjectURL).toHaveBeenCalledWith(emptyBlob);
expect(doneMock).toHaveBeenCalled();
});
test('cleans up blob URL even when download fails', async () => {
const mockAnchor = document.createElement('a');
mockAnchor.click = jest.fn().mockImplementation(() => {
throw new Error('Click failed');
});
createElementSpy.mockRestore();
createElementSpy = jest
.spyOn(document, 'createElement')
.mockReturnValue(mockAnchor);
const doneMock = jest.fn();
await expect(
handleResourceExport('dashboard', [1], doneMock),
).rejects.toThrow('Click failed');
// Verify cleanup still happens
expect(window.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
expect(doneMock).toHaveBeenCalled();
});
test('handles malformed Content-Disposition header', async () => {
mockResponse = {
headers: new Headers({
'Content-Disposition': 'not-a-valid-header',
}),
blob: jest.fn().mockResolvedValue(mockBlob),
} as unknown as Response;
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
(contentDisposition.parse as jest.Mock).mockImplementationOnce(() => {
throw new Error('Parse error');
});
const doneMock = jest.fn();
await handleResourceExport('dataset', [5], doneMock);
// Should fall back to default filename
const anchor = document.createElement('a');
expect(anchor.download).toBe('dataset_export.zip');
expect(logging.warn).toHaveBeenCalledWith(
'Failed to parse Content-Disposition header:',
expect.any(Error),
);
});
test('handles missing headers object', async () => {
mockResponse = {
headers: new Headers(),
blob: jest.fn().mockResolvedValue(mockBlob),
} as unknown as Response;
(SupersetClient.get as jest.Mock).mockResolvedValue(mockResponse);
const doneMock = jest.fn();
await handleResourceExport('chart', [7], doneMock);
const anchor = document.createElement('a');
expect(anchor.download).toBe('chart_export.zip');
expect(doneMock).toHaveBeenCalled();
});
test('handles export with empty IDs array', async () => {
const doneMock = jest.fn();
await handleResourceExport('dashboard', [], doneMock);
expect(SupersetClient.get).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: '/api/v1/dashboard/export/?q=!()',
}),
);
});

View File

@@ -16,34 +16,84 @@
* specific language governing permissions and limitations
* under the License.
*/
import parseCookie from 'src/utils/parseCookie';
import { SupersetClient, logging } from '@superset-ui/core';
import rison from 'rison';
import { nanoid } from 'nanoid';
import contentDisposition from 'content-disposition';
import { ensureAppRoot } from './pathUtils';
export default function handleResourceExport(
// Maximum blob size for in-memory downloads (100MB)
const MAX_BLOB_SIZE = 100 * 1024 * 1024;
/**
* Downloads a blob as a file using a temporary anchor element
* @param blob - The blob to download
* @param fileName - The filename to use for the download
*/
function downloadBlob(blob: Blob, fileName: string): void {
const url = window.URL.createObjectURL(blob);
try {
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
} finally {
window.URL.revokeObjectURL(url);
}
}
export default async function handleResourceExport(
resource: string,
ids: number[],
done: () => void,
interval = 200,
): void {
const token = nanoid();
const url = ensureAppRoot(
`/api/v1/${resource}/export/?q=${rison.encode(ids)}&token=${token}`,
): Promise<void> {
const endpoint = ensureAppRoot(
`/api/v1/${resource}/export/?q=${rison.encode(ids)}`,
);
// create new iframe for export
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = url;
document.body.appendChild(iframe);
try {
// Use fetch with blob response instead of iframe to avoid CSP frame-src violations
const response = await SupersetClient.get({
endpoint,
headers: {
Accept: 'application/zip, application/x-zip-compressed, text/plain',
},
parseMethod: 'raw',
});
const timer = window.setInterval(() => {
const cookie: { [cookieId: string]: string } = parseCookie();
if (cookie[token] === 'done') {
window.clearInterval(timer);
document.body.removeChild(iframe);
done();
// Check content length to prevent memory issues with large exports
const contentLength = response.headers.get('Content-Length');
if (contentLength && parseInt(contentLength, 10) > MAX_BLOB_SIZE) {
logging.warn(
`Export file size (${contentLength} bytes) exceeds maximum blob size (${MAX_BLOB_SIZE} bytes). Large exports may cause memory issues.`,
);
}
}, interval);
// Parse filename from Content-Disposition header
const disposition = response.headers.get('Content-Disposition');
let fileName = `${resource}_export.zip`;
if (disposition) {
try {
const parsed = contentDisposition.parse(disposition);
if (parsed?.parameters?.filename) {
fileName = parsed.parameters.filename;
}
} catch (error) {
logging.warn('Failed to parse Content-Disposition header:', error);
}
}
// Convert response to blob and trigger download
const blob = await response.blob();
downloadBlob(blob, fileName);
done();
} catch (error) {
logging.error('Resource export failed:', error);
done();
throw error;
}
}