fix(export): replace iframe with fetch to avoid CSP frame-src violations (#35584)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Elizabeth Thompson
2025-10-10 14:58:13 -07:00
committed by GitHub
parent e437ae1f2f
commit 3dcf85caef
12 changed files with 539 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) =>