Compare commits

...

2 Commits

Author SHA1 Message Date
Evan
248758843f test(dashboard): pin saveDashboardRequest 403 toast mapping via the thunk
The helper-level test guarded getErrorText, but saveDashboardRequest's
onError duplicates that mapping and never calls the helper, so a
dashboard-path regression would slip through. Exercise the thunk itself
with both 403 shapes (non-JSON proxy body vs the API's JSON Forbidden).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 21:43:49 -07:00
Evan Rusackas
cac16315f0 test(core): pin dashboard save-error toast mapping for non-JSON 403 responses
An admin-reported 'error saving this dashboard: Forbidden' toast (#42239)
can only be produced by a 403 whose body is not the API's JSON
{"message": "Forbidden"} shape, since that shape maps to the
permission-denied copy instead. Pin both mappings so the generic toast
remains a reliable signal that the 403 originated outside Superset
(reverse proxy, WAF, SSO gateway).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 10:13:05 -07:00
2 changed files with 93 additions and 0 deletions

View File

@@ -336,3 +336,29 @@ test('getErrorText', async () => {
),
).toEqual('Sorry, an unknown error occurred.');
});
test('getErrorText for a non-JSON 403 response', async () => {
// A 403 originating outside Superset (reverse proxy, WAF, SSO gateway)
// carries an HTML or plain-text body instead of the API's JSON
// `{"message": "Forbidden"}`, so it must fall back to the generic
// status-derived text rather than the permission-denied copy.
const proxyForbidden = new Response(
'<html><head><title>403 Forbidden</title></head><body>Forbidden</body></html>',
{
status: 403,
statusText: 'Forbidden',
headers: { 'Content-Type': 'text/html' },
},
);
expect(await getErrorText(proxyForbidden, 'dashboard')).toEqual(
'Sorry, there was an error saving this dashboard: Forbidden',
);
const supersetForbidden = new Response(
JSON.stringify({ message: 'Forbidden' }),
{ status: 403, statusText: 'FORBIDDEN' },
);
expect(await getErrorText(supersetForbidden, 'dashboard')).toEqual(
'You do not have permission to edit this dashboard',
);
});

View File

@@ -298,6 +298,73 @@ describe('dashboardState actions', () => {
{ event: 'dashboard_properties_changed' },
);
});
// The save-error toast mapping lives inline in `onError`, not behind
// `getErrorText`, so these exercise the thunk itself. A 403 whose body is
// the API's `{"message": "Forbidden"}` shape must surface the
// permission-denied copy, while a 403 from outside Superset (reverse proxy,
// WAF, SSO gateway) carries a non-JSON body and must fall back to the
// generic status-derived toast. See #42239.
const findDangerToast = (dispatch: jest.Mock) =>
dispatch.mock.calls
.map(call => call[0])
.find(
action =>
action?.type === ADD_TOAST &&
action.payload.toastType === ToastType.Danger,
);
test('maps a non-JSON 403 save failure to the generic error toast', async () => {
const { getState, dispatch } = setup();
putStub.mockRestore();
putStub = jest.spyOn(SupersetClient, 'put').mockRejectedValue(
new Response(
'<html><head><title>403 Forbidden</title></head><body>Forbidden</body></html>',
{
status: 403,
statusText: 'Forbidden',
headers: { 'Content-Type': 'text/html' },
},
),
);
const thunk = saveDashboardRequest(
newDashboardData,
192,
SAVE_TYPE_OVERWRITE,
);
await thunk(dispatch, getState);
await waitFor(() =>
expect(findDangerToast(dispatch)?.payload.text).toBe(
'Sorry, there was an error saving this dashboard: Forbidden',
),
);
});
test('maps a Superset JSON 403 save failure to the permission-denied toast', async () => {
const { getState, dispatch } = setup();
putStub.mockRestore();
putStub = jest.spyOn(SupersetClient, 'put').mockRejectedValue(
new Response(JSON.stringify({ message: 'Forbidden' }), {
status: 403,
statusText: 'FORBIDDEN',
}),
);
const thunk = saveDashboardRequest(
newDashboardData,
192,
SAVE_TYPE_OVERWRITE,
);
await thunk(dispatch, getState);
await waitFor(() =>
expect(findDangerToast(dispatch)?.payload.text).toBe(
'You do not have permission to edit this dashboard',
),
);
});
});
test('fetchCharts returns a Promise that resolves after all refreshes', async () => {