Skip to content

Commit 96cb603

Browse files
authored
fix(explore): Display missing dataset for denied access (#34129)
1 parent 94d4711 commit 96cb603

File tree

10 files changed

+263
-78
lines changed

10 files changed

+263
-78
lines changed

superset-frontend/packages/superset-ui-chart-controls/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export interface Dataset {
9090
database?: Record<string, unknown>;
9191
normalize_columns?: boolean;
9292
always_filter_main_dttm?: boolean;
93+
extra?: object | string;
9394
}
9495

9596
export interface ControlPanelState {

superset-frontend/src/explore/components/controls/DatasourceControl/DatasourceControl.test.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,3 +479,30 @@ test('should show missing dataset state', () => {
479479
),
480480
).toBeVisible();
481481
});
482+
483+
test('should show forbidden dataset state', () => {
484+
// @ts-ignore
485+
delete window.location;
486+
// @ts-ignore
487+
window.location = { search: '?slice_id=152' };
488+
const error = {
489+
error_type: 'TABLE_SECURITY_ACCESS_ERROR',
490+
statusText: 'FORBIDDEN',
491+
message: 'You do not have access to the following tables: blocked_table',
492+
extra: {
493+
datasource: 152,
494+
datasource_name: 'forbidden dataset',
495+
},
496+
};
497+
const props = createProps({
498+
datasource: {
499+
...fallbackExploreInitialData.dataset,
500+
extra: {
501+
error,
502+
},
503+
},
504+
});
505+
render(<DatasourceControl {...props} />, { useRedux: true, useRouter: true });
506+
expect(screen.getByText(error.message)).toBeInTheDocument();
507+
expect(screen.getByText(error.statusText)).toBeVisible();
508+
});

superset-frontend/src/explore/components/controls/DatasourceControl/index.jsx

Lines changed: 54 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
userHasPermission,
5151
isUserAdmin,
5252
} from 'src/dashboard/util/permissionUtils';
53+
import { ErrorMessageWithStackTrace } from 'src/components/ErrorMessage/ErrorMessageWithStackTrace';
5354
import ViewQueryModalFooter from 'src/explore/components/controls/ViewQueryModalFooter';
5455
import ViewQuery from 'src/explore/components/controls/ViewQuery';
5556
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
@@ -281,7 +282,17 @@ class DatasourceControl extends PureComponent {
281282
showSaveDatasetModal,
282283
} = this.state;
283284
const { datasource, onChange, theme } = this.props;
284-
const isMissingDatasource = !datasource?.id;
285+
let extra;
286+
if (datasource?.extra) {
287+
if (typeof datasource.extra === 'string') {
288+
try {
289+
extra = JSON.parse(datasource.extra);
290+
} catch {} // eslint-disable-line no-empty
291+
} else {
292+
extra = datasource.extra; // eslint-disable-line prefer-destructuring
293+
}
294+
}
295+
const isMissingDatasource = !datasource?.id || Boolean(extra?.error);
285296
let isMissingParams = false;
286297
if (isMissingDatasource) {
287298
const datasourceId = getUrlParam(URL_PARAMS.datasourceId);
@@ -386,20 +397,10 @@ class DatasourceControl extends PureComponent {
386397

387398
const { health_check_message: healthCheckMessage } = datasource;
388399

389-
let extra;
390-
if (datasource?.extra) {
391-
if (typeof datasource.extra === 'string') {
392-
try {
393-
extra = JSON.parse(datasource.extra);
394-
} catch {} // eslint-disable-line no-empty
395-
} else {
396-
extra = datasource.extra; // eslint-disable-line prefer-destructuring
397-
}
398-
}
399-
400-
const titleText = isMissingDatasource
401-
? t('Missing dataset')
402-
: getDatasourceTitle(datasource);
400+
const titleText =
401+
isMissingDatasource && !datasource.name
402+
? t('Missing dataset')
403+
: getDatasourceTitle(datasource);
403404

404405
const tooltip = titleText;
405406

@@ -452,31 +453,44 @@ class DatasourceControl extends PureComponent {
452453
)}
453454
{isMissingDatasource && !isMissingParams && (
454455
<div className="error-alert">
455-
<ErrorAlert
456-
type="warning"
457-
errorType={t('Missing dataset')}
458-
descriptionPre={false}
459-
descriptionDetailsCollapsed={false}
460-
descriptionDetails={
461-
<>
462-
<p>
463-
{t(
464-
'The dataset linked to this chart may have been deleted.',
465-
)}
466-
</p>
467-
<p>
468-
<Button
469-
buttonStyle="warning"
470-
onClick={() =>
471-
this.handleMenuItemClick({ key: CHANGE_DATASET })
472-
}
473-
>
474-
{t('Swap dataset')}
475-
</Button>
476-
</p>
477-
</>
478-
}
479-
/>
456+
{extra?.error ? (
457+
<div className="error-alert">
458+
<ErrorMessageWithStackTrace
459+
title={extra.error.statusText || extra.error.message}
460+
subtitle={
461+
extra.error.statusText ? extra.error.message : undefined
462+
}
463+
error={extra.error}
464+
source="explore"
465+
/>
466+
</div>
467+
) : (
468+
<ErrorAlert
469+
type="warning"
470+
errorType={t('Missing dataset')}
471+
descriptionPre={false}
472+
descriptionDetailsCollapsed={false}
473+
descriptionDetails={
474+
<>
475+
<p>
476+
{t(
477+
'The dataset linked to this chart may have been deleted.',
478+
)}
479+
</p>
480+
<p>
481+
<Button
482+
buttonStyle="warning"
483+
onClick={() =>
484+
this.handleMenuItemClick({ key: CHANGE_DATASET })
485+
}
486+
>
487+
{t('Swap dataset')}
488+
</Button>
489+
</p>
490+
</>
491+
}
492+
/>
493+
)}
480494
</div>
481495
)}
482496
{showEditDatasourceModal && (

superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,10 @@ const DndFilterSelect = (props: DndFilterSelectProps) => {
9090
let extra = {};
9191
if (datasource?.extra) {
9292
try {
93-
extra = JSON.parse(datasource.extra);
93+
extra =
94+
typeof datasource.extra === 'string'
95+
? JSON.parse(datasource.extra)
96+
: datasource.extra;
9497
} catch {} // eslint-disable-line no-empty
9598
}
9699
return extra;

superset-frontend/src/explore/fixtures.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,8 @@ export const fallbackExploreInitialData: ExplorePageInitialData = {
159159
verbose_map: {},
160160
main_dttm_col: '',
161161
owners: [],
162-
datasource_name: 'missing_datasource',
163-
name: 'missing_datasource',
162+
datasource_name: '',
163+
name: '',
164164
description: null,
165165
},
166166
slice: null,

superset-frontend/src/explore/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export type Datasource = Dataset & {
6969
catalog?: string | null;
7070
schema?: string;
7171
is_sqllab_view?: boolean;
72-
extra?: string;
72+
extra?: string | object;
7373
};
7474

7575
export interface ExplorePageInitialData {

superset-frontend/src/pages/Chart/Chart.test.tsx

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import getFormDataWithExtraFilters from 'src/dashboard/util/charts/getFormDataWi
3131
import { URL_PARAMS } from 'src/constants';
3232
import { JsonObject, VizType } from '@superset-ui/core';
3333
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
34+
import { getParsedExploreURLParams } from 'src/explore/exploreUtils/getParsedExploreURLParams';
3435
import ChartPage from '.';
3536

3637
jest.mock('src/hooks/useUnsavedChangesPrompt', () => ({
@@ -49,6 +50,9 @@ jest.mock(
4950
),
5051
);
5152
jest.mock('src/dashboard/util/charts/getFormDataWithExtraFilters');
53+
jest.mock('src/explore/exploreUtils/getParsedExploreURLParams', () => ({
54+
getParsedExploreURLParams: jest.fn(),
55+
}));
5256

5357
describe('ChartPage', () => {
5458
beforeEach(() => {
@@ -89,6 +93,91 @@ describe('ChartPage', () => {
8993
);
9094
});
9195

96+
test('displays the dataset name and error when it is prohibited', async () => {
97+
const chartApiRoute = `glob:*/api/v1/chart/*`;
98+
const exploreApiRoute = 'glob:*/api/v1/explore/*';
99+
const expectedDatasourceName = 'failed datasource name';
100+
(getParsedExploreURLParams as jest.Mock).mockReturnValue(
101+
new Map([['datasource_id', 1]]),
102+
);
103+
fetchMock.get(exploreApiRoute, () => {
104+
class Extra {
105+
datasource = 123;
106+
107+
datasource_name = expectedDatasourceName;
108+
}
109+
class SupersetSecurityError {
110+
message = 'You do not have a permission to the table';
111+
112+
extra = new Extra();
113+
}
114+
throw new SupersetSecurityError();
115+
});
116+
fetchMock.get(chartApiRoute, 200);
117+
const { getByTestId } = render(<ChartPage />, {
118+
useRouter: true,
119+
useRedux: true,
120+
useDnd: true,
121+
});
122+
await waitFor(
123+
() =>
124+
expect(getByTestId('mock-explore-chart-panel')).toHaveTextContent(
125+
JSON.stringify({ datasource_name: expectedDatasourceName }).slice(
126+
1,
127+
-1,
128+
),
129+
),
130+
{
131+
timeout: 5000,
132+
},
133+
);
134+
expect(fetchMock.calls(chartApiRoute).length).toEqual(0);
135+
expect(fetchMock.calls(exploreApiRoute).length).toBeGreaterThanOrEqual(1);
136+
});
137+
138+
test('fetches the chart api when explore metadata is prohibited and access from the chart link', async () => {
139+
const expectedChartId = 7;
140+
const expectedChartName = 'Unauthorized dataset owned chart name';
141+
(getParsedExploreURLParams as jest.Mock).mockReturnValue(
142+
new Map([['slice_id', expectedChartId]]),
143+
);
144+
const chartApiRoute = `glob:*/api/v1/chart/${expectedChartId}`;
145+
const exploreApiRoute = 'glob:*/api/v1/explore/*';
146+
147+
fetchMock.get(exploreApiRoute, () => {
148+
class Extra {
149+
datasource = 123;
150+
}
151+
class SupersetSecurityError {
152+
message = 'You do not have a permission to the table';
153+
154+
extra = new Extra();
155+
}
156+
throw new SupersetSecurityError();
157+
});
158+
fetchMock.get(chartApiRoute, {
159+
result: {
160+
id: expectedChartId,
161+
slice_name: expectedChartName,
162+
url: 'chartid',
163+
},
164+
});
165+
const { getByTestId, getByText } = render(<ChartPage />, {
166+
useRouter: true,
167+
useRedux: true,
168+
useDnd: true,
169+
});
170+
await waitFor(() => expect(fetchMock.calls(chartApiRoute).length).toBe(1), {
171+
timeout: 5000,
172+
});
173+
expect(fetchMock.calls(exploreApiRoute).length).toBeGreaterThanOrEqual(1);
174+
expect(getByTestId('mock-explore-chart-panel')).toBeInTheDocument();
175+
expect(getByTestId('mock-explore-chart-panel')).toHaveTextContent(
176+
JSON.stringify({ datasource: 123 }).slice(1, -1),
177+
);
178+
expect(getByText(expectedChartName)).toBeInTheDocument();
179+
});
180+
92181
describe('with dashboardContextFormData', () => {
93182
const dashboardPageId = 'mockPageId';
94183

0 commit comments

Comments
 (0)