fix(subdirectory): create-chart link, permalink doubling, and dead Superset.* routes

Round-2 follow-up to the TDD scaffold. Two user-visible bugs in QA on
the local /superset deployment:

- Empty dashboard-tab "create a new chart" link opened a 404 tab under
  /superset: raw <Typography.Link href="/chart/add?..."> with
  target="_blank" bypasses React Router's basename, so the new tab
  resolves the absolute path against the document origin without the
  application root.
- "Copy permalink" produced /superset/superset/dashboard/p/<key>/ on
  the clipboard. The same backend mechanism made the 18 routes on the
  `Superset` view class unreachable at request time under subdirectory
  deployment (404 for /superset/welcome/, /superset/dashboard/<id>/,
  /superset/explore/, etc.).

Frontend:
- Tab.tsx — wrap the create-chart href with `ensureAppRoot()` from
  navigationUtils so the application root is applied exactly once and
  the new tab lands at the right path.
- New L2 invariant `RAW_HREF_ABSOLUTE_PATH_PATTERN` flags raw
  absolute-from-root anchors (`href="/..."`, `href={`/...`}`, etc.) —
  the bug class that bypasses both helpers and React Router basename.
  Seeded with 7 remaining violator files paired with a
  `toEqual([])` stale-allow-list check so each migration commit
  shrinks the list in lockstep.
- Tab.subdirectory.test.tsx — empty / /superset / nested-root
  regression pins via a jest.mock pattern on applicationRoot().

Backend (breaking change documented in UPDATING.md):
- Override `Superset.route_base = ""` so Flask-AppBuilder's
  auto-derived `/superset` prefix no longer compounds with the
  SCRIPT_NAME / basename that AppRootMiddleware sets. url_for now
  emits single-prefix URLs and the routes are reachable under both
  root and subdirectory deployments.
- Pin the new shape with four unit tests covering
  Superset.dashboard_permalink (relative + external) and
  Superset.welcome, with and without SCRIPT_NAME.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joe Li
2026-05-14 09:51:25 -07:00
parent b3774c109e
commit 756458f031
6 changed files with 329 additions and 1 deletions

View File

@@ -26,6 +26,8 @@ assists people when migrating to a new version.
- [39925](https://github.com/apache/superset/pull/39925): URL prefixing for `SUPERSET_APP_ROOT` subdirectory deployments is now handled automatically by helpers in `src/utils/navigationUtils` (`openInNewTab`, `redirect`, `getShareableUrl`, `<AppLink>`). Direct imports of `ensureAppRoot` / `makeUrl` from `src/utils/pathUtils` are forbidden outside `navigationUtils.ts` (enforced by a static-invariant test); contributors writing new code should use the focused helpers instead. No runtime behaviour change for existing callers — all 19 prior call sites have been migrated and four pre-existing double-prefix and missing-prefix bugs are fixed as part of the migration.
- **Breaking — `Superset` view class route prefix removed.** The `Superset` view in `superset/views/core.py` now declares `route_base = ""`, overriding Flask-AppBuilder's auto-derived `/superset` prefix. Routes that previously lived at `/superset/welcome/`, `/superset/dashboard/<id>/`, `/superset/dashboard/p/<key>/`, `/superset/explore/`, etc. now respond at `/welcome/`, `/dashboard/<id>/`, `/dashboard/p/<key>/`, `/explore/`, etc. Under subdirectory deployment (`SUPERSET_APP_ROOT=/superset`) the URLs are unchanged from end-user perspective — `AppRootMiddleware` re-applies the prefix via `SCRIPT_NAME`. Under root deployments, any external integration or bookmark that hard-codes `/superset/<endpoint>/` paths must be updated to drop the prefix. This fixes the doubled `/superset/superset/...` URLs that `url_for` emitted for these endpoints under subdirectory deployment and the related 404s on the routes themselves.
### Granular Export Controls
A new feature flag `GRANULAR_EXPORT_CONTROLS` introduces three fine-grained permissions that replace the legacy `can_csv` permission:

View File

@@ -0,0 +1,146 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { FC } from 'react';
import { render, screen } from 'spec/helpers/testing-library';
// Tab.tsx is statically imported below; the mock pattern intercepts
// applicationRoot() rather than relying on withApplicationRoot (which is for
// dynamic-import unit tests).
const mockApplicationRoot = jest.fn<string, []>(() => '');
jest.mock('src/utils/getBootstrapData', () => {
const actual = jest.requireActual<
typeof import('src/utils/getBootstrapData')
>('src/utils/getBootstrapData');
return {
__esModule: true,
default: actual.default,
applicationRoot: () => mockApplicationRoot(),
staticAssetsPrefix: actual.staticAssetsPrefix,
};
});
jest.mock('src/dashboard/util/getChartIdsFromComponent', () =>
jest.fn(() => []),
);
jest.mock('src/dashboard/containers/DashboardComponent', () =>
jest.fn(() => <div data-test="DashboardComponent" />),
);
jest.mock('@superset-ui/core/components/EditableTitle', () => ({
__esModule: true,
EditableTitle: jest.fn(() => <div data-test="EditableTitle" />),
}));
jest.mock('src/dashboard/components/dnd/DragDroppable', () => ({
...jest.requireActual('src/dashboard/components/dnd/DragDroppable'),
Droppable: jest.fn(props => (
<div>{props.children ? props.children({}) : null}</div>
)),
}));
// eslint-disable-next-line import/first
import ActualTab from './Tab';
const Tab = ActualTab as unknown as FC<Record<string, unknown>>;
const DASHBOARD_ID = 23;
const buildProps = () => ({
id: 'TAB-empty-',
parentId: 'TABS-empty-',
depth: 2,
index: 0,
renderType: 'RENDER_TAB_CONTENT',
availableColumnCount: 12,
columnWidth: 120,
isFocused: false,
component: {
children: [],
id: 'TAB-empty-',
meta: { text: 'Empty Tab' },
parents: ['ROOT_ID', 'GRID_ID', 'TABS-empty-'],
type: 'TAB',
},
parentComponent: {
children: ['TAB-empty-'],
id: 'TABS-empty-',
meta: {},
parents: ['ROOT_ID', 'GRID_ID'],
type: 'TABS',
},
editMode: true,
embeddedMode: false,
undoLength: 0,
redoLength: 0,
filters: {},
directPathToChild: [],
directPathLastUpdated: 0,
dashboardId: DASHBOARD_ID,
focusedFilterScope: null,
isComponentVisible: true,
onDropOnTab: jest.fn(),
handleComponentDrop: jest.fn(),
updateComponents: jest.fn(),
setDirectPathToChild: jest.fn(),
onResizeStart: jest.fn(),
onResize: jest.fn(),
onResizeStop: jest.fn(),
});
const renderEmptyEditModeTab = () =>
render(<Tab {...buildProps()} />, {
useRedux: true,
useDnd: true,
initialState: {
dashboardInfo: { dash_edit_perm: true },
},
});
beforeEach(() => {
mockApplicationRoot.mockReturnValue('');
});
test('Tab — empty edit-mode "create a new chart" link is unprefixed when application root is empty', () => {
mockApplicationRoot.mockReturnValue('');
renderEmptyEditModeTab();
expect(
screen.getByRole('link', { name: 'create a new chart' }),
).toHaveAttribute('href', `/chart/add?dashboard_id=${DASHBOARD_ID}`);
});
test('Tab — empty edit-mode "create a new chart" link carries the application root under subdirectory deployment', () => {
mockApplicationRoot.mockReturnValue('/superset');
renderEmptyEditModeTab();
// Single prefix — not /superset/superset/ — verifying ensureAppRoot's
// dedupe boundary holds against the path's leading slash.
expect(
screen.getByRole('link', { name: 'create a new chart' }),
).toHaveAttribute('href', `/superset/chart/add?dashboard_id=${DASHBOARD_ID}`);
});
test('Tab — empty edit-mode "create a new chart" link prefixes correctly for nested subdirectory roots', () => {
mockApplicationRoot.mockReturnValue('/a/b/c');
renderEmptyEditModeTab();
expect(
screen.getByRole('link', { name: 'create a new chart' }),
).toHaveAttribute('href', `/a/b/c/chart/add?dashboard_id=${DASHBOARD_ID}`);
});

View File

@@ -36,6 +36,7 @@ import getChartIdsFromComponent from 'src/dashboard/util/getChartIdsFromComponen
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
import AnchorLink from 'src/dashboard/components/AnchorLink';
import { Typography } from '@superset-ui/core/components/Typography';
import { ensureAppRoot } from 'src/utils/navigationUtils';
import {
useIsAutoRefreshing,
useIsRefreshInFlight,
@@ -333,7 +334,9 @@ const Tab = (props: TabProps): ReactElement => {
<span>
{t('You can')}{' '}
<Typography.Link
href={`/chart/add?dashboard_id=${dashboardId}`}
href={ensureAppRoot(
`/chart/add?dashboard_id=${dashboardId}`,
)}
rel="noopener noreferrer"
target="_blank"
>

View File

@@ -36,3 +36,59 @@ test('no file outside navigationUtils.ts imports from pathUtils', () => {
'getShareableUrl, AppLink) for most call sites.',
);
});
// Raw absolute-from-root anchor hrefs (`href="/foo"`, `href={`/foo`}`, etc.)
// bypass React Router's basename when rendered with `target="_blank"` and
// produce 404s under subdirectory deployment. Migrate to either
// `<AppLink href={...}>` or wrap with `ensureAppRoot()` so the application
// root is applied exactly once.
//
// The negative lookbehind `(?<!\.)` excludes `obj.href = '/'` — that case
// belongs to the navigateTo / window.location migration. Negative lookahead
// `(?!\/)` excludes protocol-relative URLs (`href="//cdn..."`).
const RAW_HREF_ABSOLUTE_PATH_PATTERN =
/(?<!\.)\bhref\s*=\s*(?:["']\/(?!\/)|\{\s*["']\/(?!\/)|\{\s*`\/(?!\/))/;
const RAW_HREF_ABSOLUTE_PATH_ALLOWLIST: string[] = [
'src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.tsx',
'src/pages/AnnotationLayerList/index.tsx',
'src/pages/AnnotationList/index.tsx',
'src/pages/DatabaseList/index.tsx',
'src/pages/DatasetList/index.tsx',
'src/pages/Login/index.tsx',
'src/pages/Register/index.tsx',
];
test('no raw absolute-path href in JSX outside the migration allow-list', () => {
const hits = scanSource({
pattern: RAW_HREF_ABSOLUTE_PATH_PATTERN,
allowlist: RAW_HREF_ABSOLUTE_PATH_ALLOWLIST,
});
expectNoHits(
hits,
'Found raw absolute-path href anchors. With target="_blank" these ' +
"bypass React Router's basename and 404 under /superset deployment. " +
'Migrate to <AppLink href={...}> from src/utils/navigationUtils, or ' +
'wrap the value with ensureAppRoot().',
);
});
test('RAW_HREF_ABSOLUTE_PATH_ALLOWLIST has no stale entries', () => {
// Every allow-listed file must still contain at least one raw-href
// violation. When a migration commit removes the last violation from a
// file, that file must be dropped from the allow-list in the same commit
// — otherwise the scan silently stops protecting the rest of the tree
// from regressions in that file.
const hitFiles = new Set(
scanSource({ pattern: RAW_HREF_ABSOLUTE_PATH_PATTERN }).map(
hit => hit.file,
),
);
const stale = RAW_HREF_ABSOLUTE_PATH_ALLOWLIST.filter(
file => !hitFiles.has(file),
);
expect(stale).toEqual([]);
});

View File

@@ -134,6 +134,16 @@ SqlResults = dict[str, Any]
class Superset(BaseSupersetView):
"""The base views for Superset!"""
# Flask-AppBuilder's BaseView auto-derives `route_base` from the class
# name, which would mount every @expose route under `/superset/...`. That
# collides catastrophically with subdirectory deployments: AppRootMiddle-
# ware strips the configured root from PATH_INFO and sets SCRIPT_NAME, so
# url_for emits `/superset/superset/...` (doubled), and the in-rule
# `/superset` prefix no longer matches the post-strip PATH_INFO — making
# the routes themselves unreachable. Mount at the root so the application
# root applies exactly once via SCRIPT_NAME / basename.
route_base = ""
logger = logging.getLogger(__name__)
@has_access

View File

@@ -0,0 +1,111 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""url_for emission for Superset.* endpoints under subdirectory deployments.
Flask-AppBuilder's BaseView auto-derives `route_base` from the class name,
which used to mount every `@expose` route on `Superset` under `/superset/...`.
Combined with `AppRootMiddleware` stripping `/superset` from `PATH_INFO` and
setting `SCRIPT_NAME=/superset`, werkzeug's `MapAdapter.build` produced
doubled URLs (`/superset/superset/...`) on every `url_for(..., _external=True)`
call into that namespace — and the routes themselves became unreachable at
request time because the in-rule `/superset` prefix no longer matched the
post-strip PATH_INFO.
`Superset.route_base = ""` mounts the routes at the root so the appRoot
applies exactly once (via SCRIPT_NAME / basename). These tests pin both
branches: no SCRIPT_NAME and SCRIPT_NAME=`/superset`.
"""
from flask import url_for
def test_dashboard_permalink_url_has_no_route_prefix_without_script_name(
app_context: None,
) -> None:
"""Under root deployment, the permalink route lives at /dashboard/p/<key>/.
The auto-derived `/superset` prefix on the `Superset` view class is gone;
the route is now mounted at the root path so url_for returns a single,
prefix-free URL.
"""
from flask import current_app
with current_app.test_request_context("/"):
url = url_for("Superset.dashboard_permalink", key="abc123")
assert url == "/dashboard/p/abc123/"
def test_dashboard_permalink_url_carries_single_script_name_prefix(
app_context: None,
) -> None:
"""Under subdirectory deployment, url_for emits exactly one prefix.
AppRootMiddleware sets SCRIPT_NAME=/superset on every inbound request
once APPLICATION_ROOT is configured. url_for prepends SCRIPT_NAME to the
rule, so the emitted URL is `/superset/dashboard/p/<key>/` — a single
prefix, not the previous `/superset/superset/dashboard/p/<key>/`.
"""
from flask import current_app
with current_app.test_request_context(
"/",
environ_overrides={"SCRIPT_NAME": "/superset"},
):
url = url_for("Superset.dashboard_permalink", key="abc123")
assert url == "/superset/dashboard/p/abc123/"
def test_welcome_url_carries_single_script_name_prefix(
app_context: None,
) -> None:
"""Spot-check a second route to confirm the fix is not endpoint-specific.
The `brand.path` regression in the QA findings traced to
`url_for("Superset.welcome", _external=True)` returning the doubled
`/superset/superset/welcome/`. Pinning a single-prefix expectation for
welcome guards against a regression that reintroduces the auto-derived
route_base on the Superset class.
"""
from flask import current_app
with current_app.test_request_context(
"/",
environ_overrides={"SCRIPT_NAME": "/superset"},
):
url = url_for("Superset.welcome")
assert url == "/superset/welcome/"
def test_dashboard_permalink_external_url_is_single_prefixed(
app_context: None,
) -> None:
"""`url_for(..., _external=True)` is the shape the permalink API serves.
Pin the external (scheme://host included) variant explicitly — that is
the value that ends up on the user's clipboard via
`superset/dashboards/permalink/api.py` and must carry one application
root segment, not two.
"""
from flask import current_app
with current_app.test_request_context(
"/",
environ_overrides={"SCRIPT_NAME": "/superset"},
):
url = url_for("Superset.dashboard_permalink", key="abc123", _external=True)
assert url.endswith("/superset/dashboard/p/abc123/")
assert "/superset/superset/" not in url