Compare commits

...

1 Commits

Author SHA1 Message Date
Elizabeth Thompson
86fe4fc8b2 fix(export): fix double app-root prefix in chart/drill-detail export URLs
When Superset is deployed as a subdirectory (SUPERSET_APP_ROOT), export
URLs were getting the app root applied twice. SupersetClient.postForm()
adds the appRoot internally via getUrl(), so callers must not pre-apply
ensureAppRoot() before passing an endpoint to any SupersetClient method.

Fix:
- exportChart: build URL without appRoot; apply ensureAppRoot only for
  the streaming path (native fetch), leave raw for postForm path
- DrillDetailPane: remove ensureAppRoot() before SupersetClient.postForm()
- chartAction: remove ensureAppRoot() before SupersetClient.postForm()
- Add getExploreUrl includeAppRoot param to support building unprefixed
  legacy API URLs for the non-streaming fallback
- Add regression test: postForm must receive unprefixed URL even when
  SUPERSET_APP_ROOT is configured

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 01:23:10 +00:00
4 changed files with 36 additions and 11 deletions

View File

@@ -50,7 +50,6 @@ import Table, {
import { RootState } from 'src/dashboard/types';
import { usePermissions } from 'src/hooks/usePermissions';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { ensureAppRoot } from 'src/utils/pathUtils';
import { safeStringify } from 'src/utils/safeStringify';
import HeaderWithRadioGroup from '@superset-ui/core/components/Table/header-renderers/HeaderWithRadioGroup';
import { useDatasetMetadataBar } from 'src/features/datasets/metadataBar/useDatasetMetadataBar';
@@ -248,7 +247,7 @@ export default function DrillDetailPane({
if (dashboardId) {
payload.form_data = { dashboardId };
}
SupersetClient.postForm(ensureAppRoot('/api/v1/chart/data'), {
SupersetClient.postForm('/api/v1/chart/data', {
form_data: safeStringify(payload),
}).catch(error => {
addDangerToast(

View File

@@ -48,7 +48,6 @@ import { Logger, LOG_ACTIONS_LOAD_CHART } from 'src/logger/LogUtils';
import { allowCrossDomain as domainShardingEnabled } from 'src/utils/hostNamesConfig';
import { updateDataMask } from 'src/dataMask/actions';
import { waitForAsyncData } from 'src/middleware/asyncEvent';
import { ensureAppRoot } from 'src/utils/pathUtils';
import { safeStringify } from 'src/utils/safeStringify';
import { extendedDayjs } from '@superset-ui/core/utils/dates';
import type { Dispatch, Action, AnyAction } from 'redux';
@@ -934,7 +933,7 @@ export function redirectSQLLab(
requestedQuery: payload,
});
} else {
SupersetClient.postForm(ensureAppRoot(redirectUrl), {
SupersetClient.postForm(redirectUrl, {
form_data: safeStringify(payload),
});
}

View File

@@ -58,7 +58,9 @@ beforeEach(() => {
});
});
// Tests for exportChart URL prefix handling in streaming export
// Tests for exportChart URL prefix handling in streaming export.
// Streaming uses native fetch (not SupersetClient), so exportChart must apply
// ensureAppRoot before passing the URL to onStartStreamingExport.
test('exportChart v1 API passes prefixed URL to onStartStreamingExport when app root is configured', async () => {
const appRoot = '/superset';
ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`);
@@ -111,6 +113,24 @@ test('exportChart v1 API passes nested prefix for deeply nested deployments', as
expect(callArgs.exportType).toBe('xlsx');
});
// Regression test for the double-prefix bug: SupersetClient.postForm adds appRoot
// internally via getUrl(), so the URL passed must NOT already be prefixed.
test('exportChart v1 API calls postForm with unprefixed URL when app root is configured', async () => {
const { SupersetClient } = jest.requireMock('@superset-ui/core');
const appRoot = '/analytics';
ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`);
await exportChart({
formData: baseFormData,
resultFormat: 'csv',
});
expect(SupersetClient.postForm).toHaveBeenCalledTimes(1);
const [url] = SupersetClient.postForm.mock.calls[0];
expect(url).toBe('/api/v1/chart/data');
expect(url).not.toContain(appRoot);
});
test('exportChart passes csv exportType for CSV exports', async () => {
const onStartStreamingExport = jest.fn();
@@ -143,7 +163,7 @@ test('exportChart passes xlsx exportType for Excel exports', async () => {
);
});
test('exportChart legacy API (useLegacyApi=true) passes prefixed URL with app root configured', async () => {
test('exportChart legacy API (useLegacyApi=true) passes prefixed URL to onStartStreamingExport when app root is configured', async () => {
const appRoot = '/superset';
ensureAppRoot.mockImplementation((path: string) => `${appRoot}${path}`);
@@ -165,6 +185,8 @@ test('exportChart legacy API (useLegacyApi=true) passes prefixed URL with app ro
expect(onStartStreamingExport).toHaveBeenCalledTimes(1);
const callArgs = onStartStreamingExport.mock.calls[0][0];
// The legacy blueprint path is /superset/explore_json/; with appRoot=/superset the
// full streaming URL is /superset/superset/explore_json/ (appRoot + blueprint prefix).
expect(callArgs.url).toBe('/superset/superset/explore_json/?csv=true');
expect(callArgs.exportType).toBe('csv');
});

View File

@@ -76,6 +76,7 @@ interface GetExploreUrlParams {
allowDomainSharding?: boolean;
method?: 'GET' | 'POST';
relative?: boolean;
includeAppRoot?: boolean;
}
interface BuildV1ChartDataPayloadParams {
@@ -223,6 +224,7 @@ export function getExploreUrl({
allowDomainSharding = false,
method = 'POST',
relative = false,
includeAppRoot = true,
}: GetExploreUrlParams): string | null {
if (!formData.datasource) {
return null;
@@ -242,7 +244,7 @@ export function getExploreUrl({
uri = URI(URI(curUrl).search());
}
const directory = getURIDirectory(endpointType);
const directory = getURIDirectory(endpointType, includeAppRoot);
// Building the querystring (search) part of the URI
const search = uri.search(true) as Record<string, string>;
@@ -370,10 +372,11 @@ export const exportChart = async ({
force,
allowDomainSharding: false,
relative: true,
includeAppRoot: false,
});
payload = formData;
} else {
url = ensureAppRoot('/api/v1/chart/data');
url = '/api/v1/chart/data';
payload = await buildV1ChartDataPayload({
formData,
force,
@@ -385,14 +388,16 @@ export const exportChart = async ({
// Check if streaming export handler is provided (from dashboard Chart.jsx)
if (onStartStreamingExport) {
// Streaming is handled by the caller - pass URL, payload, and export type
// Streaming uses native fetch — apply appRoot prefix here since useStreamingExport
// does not go through SupersetClient (which would add it automatically).
onStartStreamingExport({
url,
url: url ? ensureAppRoot(url) : url,
payload,
exportType: resultFormat,
});
} else {
// Fallback to original behavior for non-streaming exports
// SupersetClient.postForm calls getUrl({ endpoint }) internally, which prepends
// appRoot — so the URL must NOT be pre-prefixed here.
SupersetClient.postForm(url as string, {
form_data: safeStringify(payload),
});