Compare commits

...
Author SHA1 Message Date
Amin GhadersohiandClaude bdd0ca890b fix(sqllab): don't let a generic metadata error mask an OAuth2 redirect
Both table-metadata requests can fail at once. Always preferring
metadataError meant a generic failure there (raw 500, timeout, network
error) suppressed an actionable OAUTH2_REDIRECT payload coming from the
extended-metadata request, hiding the authorization link and the retry.

Select whichever request carries a structured errors[] payload instead,
keeping the plain-message fallback for the case where neither does.

Also adds the end-to-end assertion for the headline behavior: the
completion broadcast repopulates the preview, rather than only checking
that the link renders and the tag string is present in invalidateTags.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-05 00:25:39 +00:00
Amin GhadersohiandClaude 3547e7fce5 fix(sqllab): keep plain metadata error messages visible
Address review feedback: routing every metadata error through
ErrorMessageWithStackTrace regressed the non-OAuth2 path. The RTK base query
shapes errors as `errors: errorObj?.errors || []`, so any error without a
structured SIP-40 errors[] array (raw 500, HTML error page, timeout, network
failure) produced an undefined payload and rendered a bare "Unexpected error"
alert with no body.

Guard on the structured payload: render ErrorMessageWithStackTrace when a
SupersetError is present (preserving the OAuth2 authorization link and retry),
and fall back to the plain warning Alert with the message text otherwise. This
matches the guarded pattern used by the sibling source="crud" consumers in
TableExploreTree, DatabaseSelector and TableSelector.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-31 17:28:50 +00:00
Amin Ghadersohi 512cb9ccf0 Merge branch 'master' into aminghadersohi/sqllab-oauth2-table-preview-retry 2026-07-29 13:28:16 -04:00
Amin Ghadersohi 5a2060df16 fix(sqllab): retry table metadata preview after OAuth2 redirect
The SQL Lab table column/metadata preview rendered a plain warning Alert on
error, so an OAuth2 DB-auth 403 (error_type=OAUTH2_REDIRECT) showed a dead
banner with no authorization link and never refetched after the user completed
the OAuth2 dance elsewhere.

Render the metadata error via ErrorMessageWithStackTrace with source="crud",
passing the structured SupersetError through (error.errors[0]) so the registered
OAuth2RedirectMessage component shows the authorization link and listens for
completion, matching DatabaseSelector and TableExploreTree.

The crud-source retry in OAuth2RedirectMessage only invalidated Schemas,
Catalogs and Tables, so the table metadata queries (tagged TableMetadatas) were
never refetched after re-auth. Add TableMetadatas to the invalidated tags so the
preview auto-refreshes once the OAuth2 dance completes.
2026-07-28 21:44:06 +00:00
4 changed files with 135 additions and 8 deletions
@@ -18,7 +18,9 @@
*/
import { type ReactChild } from 'react';
import fetchMock from 'fetch-mock';
import { ErrorTypeEnum } from '@superset-ui/core';
import { table, initialState } from 'src/SqlLab/fixtures';
import setupErrorMessages from 'src/setup/setupErrorMessages';
import {
render,
waitFor,
@@ -81,6 +83,17 @@ const mockedProps = {
tableName: table.name,
};
const OAUTH2_TAB_ID = 'tab-1';
const oauth2Error = {
message: 'The database is currently not authenticated',
error_type: ErrorTypeEnum.OAUTH2_REDIRECT,
level: 'warning',
extra: {
url: 'https://example.com/oauth2/authorize',
tab_id: OAUTH2_TAB_ID,
},
};
test('renders columns', async () => {
const { getAllByTestId, queryByText } = render(
<TablePreview {...mockedProps} />,
@@ -139,6 +152,101 @@ test('renders preview', async () => {
);
});
test('renders an OAuth2 authorization prompt when metadata errors with OAUTH2_REDIRECT', async () => {
// Register the OAuth2 redirect component so ErrorMessageWithStackTrace can
// resolve it for the OAUTH2_REDIRECT error type, mirroring app setup.
setupErrorMessages();
fetchMock.removeRoutes();
fetchMock.get(getTableMetadataEndpoint, {
status: 403,
body: { errors: [oauth2Error] },
});
fetchMock.get(getExtraTableMetadataEndpoint, {});
render(<TablePreview {...mockedProps} />, { useRedux: true, initialState });
// The structured SupersetError must flow through so the OAuth2 redirect link
// renders (and the crud-source retry can re-fetch once the dance completes).
const authLink = await screen.findByRole('link', {
name: 'provide authorization',
});
expect(authLink).toHaveAttribute(
'href',
'https://example.com/oauth2/authorize',
);
});
test('surfaces an OAUTH2_REDIRECT from the extended metadata request when the main request fails generically', async () => {
setupErrorMessages();
fetchMock.removeRoutes();
fetchMock.get(getTableMetadataEndpoint, {
status: 500,
body: { message: 'Something went wrong' },
});
fetchMock.get(getExtraTableMetadataEndpoint, {
status: 403,
body: { errors: [oauth2Error] },
});
render(<TablePreview {...mockedProps} />, { useRedux: true, initialState });
// The generic failure on the main request must not mask the actionable
// OAuth2 payload coming from the extended metadata request.
expect(
await screen.findByRole('link', { name: 'provide authorization' }),
).toBeInTheDocument();
});
test('repopulates the preview once the OAuth2 dance completes', async () => {
setupErrorMessages();
fetchMock.removeRoutes();
fetchMock.get(getTableMetadataEndpoint, {
status: 403,
body: { errors: [oauth2Error] },
});
fetchMock.get(getExtraTableMetadataEndpoint, {});
const { getAllByTestId } = render(<TablePreview {...mockedProps} />, {
useRedux: true,
initialState,
});
await screen.findByRole('link', { name: 'provide authorization' });
// The second tab finished authorizing: the database now answers with the
// metadata, and the completion broadcast must drive the refetch.
fetchMock.removeRoutes();
fetchMock.get(getTableMetadataEndpoint, table);
fetchMock.get(getExtraTableMetadataEndpoint, {});
fireEvent(
window,
Object.assign(new Event('storage'), {
key: 'oauth2_auth_complete',
newValue: JSON.stringify({ tabId: OAUTH2_TAB_ID }),
}),
);
await waitFor(() =>
expect(getAllByTestId('mock-record-row')).toHaveLength(
table.columns.length,
),
);
});
test('renders the error message when metadata fails without a structured error', async () => {
fetchMock.removeRoutes();
fetchMock.get(getTableMetadataEndpoint, {
status: 500,
body: { message: 'Something went wrong' },
});
fetchMock.get(getExtraTableMetadataEndpoint, {});
render(<TablePreview {...mockedProps} />, { useRedux: true, initialState });
// Without an errors[] array there is no SupersetError to hand to the
// registry, so the plain message must still surface to the user.
expect(await screen.findByText('Something went wrong')).toBeInTheDocument();
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('table actions', () => {
test('refreshes table metadata when triggered', async () => {
@@ -34,7 +34,11 @@ import {
import AutoSizer from 'react-virtualized-auto-sizer';
import { Icons } from '@superset-ui/core/components/Icons';
import type { SqlLabRootState } from 'src/SqlLab/types';
import { CopyToClipboard, FilterableTable } from 'src/components';
import {
CopyToClipboard,
ErrorMessageWithStackTrace,
FilterableTable,
} from 'src/components';
import Tabs from '@superset-ui/core/components/Tabs';
import {
tableApiUtil,
@@ -257,13 +261,26 @@ const TablePreview: FC<Props> = ({ dbId, catalog, schema, tableName }) => {
}
if (hasMetadataError || metadataExtrError) {
return (
<Alert
type="warning"
message={
((metadataError || metadataExtrError) as ClientErrorObject)?.error
}
/>
// Pass the structured SupersetError through to ErrorMessageWithStackTrace so
// that OAuth2 redirect errors (error_type=OAUTH2_REDIRECT) render the
// "Authorization needed" link and auto-retry once the OAuth2 dance completes.
// The source="crud" value drives the tag-invalidation retry in
// OAuth2RedirectMessage.tsx (which invalidates the TableMetadatas tag).
// Errors without a structured errors[] array (raw 500s, timeouts, network
// failures) fall back to the plain message so it isn't swallowed.
// Both requests can fail at once, so pick whichever carries a structured
// payload rather than always preferring the main request — otherwise a
// generic failure there would mask an actionable OAUTH2_REDIRECT from the
// extended-metadata request.
const clientErrors = [metadataError, metadataExtrError].filter(
Boolean,
) as ClientErrorObject[];
const errorPayload = clientErrors.find(({ errors }) => errors?.length)
?.errors?.[0];
return errorPayload ? (
<ErrorMessageWithStackTrace error={errorPayload} source="crud" />
) : (
<Alert type="warning" message={clientErrors[0]?.error} />
);
}
if (!data) {
@@ -231,6 +231,7 @@ describe('OAuth2RedirectMessage Component', () => {
{ type: 'Schemas', id: 'LIST' },
{ type: 'Catalogs', id: 'LIST' },
'Tables',
'TableMetadatas',
]);
});
});
@@ -121,6 +121,7 @@ export function OAuth2RedirectMessage({
{ type: 'Schemas', id: 'LIST' },
{ type: 'Catalogs', id: 'LIST' },
'Tables',
'TableMetadatas',
]),
);
}