Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
be43d00
test(subdirectory): scaffold red/green tests for application root URL…
sadpandajoe May 7, 2026
fd7c7d9
style: apply prettier line-wrapping in skeleton modules
sadpandajoe May 7, 2026
e9cb5c4
feat(subdirectory): implement application root URL helpers and backen…
sadpandajoe May 7, 2026
6ccb968
fix(subdirectory): unblock CI on subdirectory-helpers PR
sadpandajoe May 7, 2026
59cee58
fix(subdirectory): add navigation URL scheme allow-list to satisfy Co…
sadpandajoe May 7, 2026
38c7236
fix(subdirectory): collapse redirect into navigateTo to clear CodeQL …
sadpandajoe May 7, 2026
434131d
fix(subdirectory): reorder navigationUtils so primitives precede helpers
sadpandajoe May 7, 2026
bd63151
style: collapse SAFE_NAVIGATION_URL_RE onto one line per prettier
sadpandajoe May 7, 2026
11a47c6
fix(subdirectory): preserve default export when mocking getBootstrapData
sadpandajoe May 7, 2026
7d6d33f
fix(subdirectory): use explicit __esModule mock shape for getBootstra…
sadpandajoe May 7, 2026
5794946
fix(subdirectory): avoid TDZ on mockApplicationRoot during mock facto…
sadpandajoe May 7, 2026
e24f9d8
fix(subdirectory): skip invariants scan to isolate shard-6 hang
sadpandajoe May 7, 2026
986f19c
fix(subdirectory): drop disabled-test, remove unused imports
sadpandajoe May 7, 2026
cfaeb11
fix(subdirectory): reinstate invariants scan, harden scanner
sadpandajoe May 8, 2026
34f1b27
refactor(subdirectory): trim over-commented helpers and tests
sadpandajoe May 8, 2026
678d5f3
style: apply prettier line-wrap to normalizeBackendUrls.test.ts
sadpandajoe May 8, 2026
0f78cfd
feat(frontend): migrate all subdirectory call sites to navigationUtil…
sadpandajoe May 8, 2026
ac5055b
style: re-apply prettier 3.8.3 formatting to QueryTable
sadpandajoe May 8, 2026
6d6a1d8
fix(ts): allow undefined appRoot in normalizeJsonResponse signature
sadpandajoe May 8, 2026
686ad4e
test(explore): update ViewQuery to expect openInNewTab third arg
sadpandajoe May 8, 2026
7538a15
revert(subdirectory): remove SupersetClient response normaliser wiring
sadpandajoe May 8, 2026
3141d92
test(subdirectory): cover redirect, getShareableUrl, AppLink, and wal…
sadpandajoe May 8, 2026
8657111
test(subdirectory): split AppLink tests into a tsx file with mock pat…
sadpandajoe May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* 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.
*/

/**
* Strips the configured application root from URL fields in API responses so
* the frontend always speaks router-relative paths. Without normalisation,
* `SupersetClient` and `<Link>` would re-prefix backend-supplied URLs and
* produce `/foo/foo/...`.
*/

/** Field names known to be router-relative URLs to this Superset instance. */
export const NORMALIZED_URL_FIELDS = new Set<string>(['explore_url']);

/**
* URL-shaped fields that look normalisable but are deliberately left alone
* (external destinations, CDN hosts, OAuth endpoints, deployment-dependent
* targets). Informational only — keep in sync with the negative tests.
*/
export const NORMALIZER_EXCLUSIONS: ReadonlyArray<{
field: string;
reason: string;
}> = [
{ field: 'bug_report_url', reason: 'External (GitHub)' },
{ field: 'documentation_url', reason: 'External (docs site)' },
{ field: 'external_url', reason: 'External by name' },
{ field: 'bundle_url', reason: 'CDN / static asset host' },
{ field: 'tracking_url', reason: 'External (analytics)' },
{ field: 'user_login_url', reason: 'OAuth / SSO endpoint, may be external' },
{ field: 'user_logout_url', reason: 'OAuth / SSO endpoint, may be external' },
{ field: 'user_info_url', reason: 'OAuth / SSO endpoint, may be external' },
{ field: 'thumbnail_url', reason: 'Storage host varies (S3 / local)' },
{ field: 'creator_url', reason: 'User-profile destination varies' },
];

export interface NormalizeOptions {
/** Application root to strip. Empty string disables normalisation. */
applicationRoot: string;
}

const SAFE_ABSOLUTE_URL_RE = /^(?:https?|ftp|mailto|tel):/i;

function stripTrailingSlash(root: string): string {
return root.endsWith('/') ? root.slice(0, -1) : root;
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== 'object') return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}

/** Normalise a single URL string (used directly when walking is overkill). */
export function normalizeBackendUrlString(
value: string,
options: NormalizeOptions,
): string {
const root = stripTrailingSlash(options.applicationRoot);
if (!root) return value;
if (SAFE_ABSOLUTE_URL_RE.test(value)) return value;
if (value.startsWith('//')) return value;
if (value === root) return '/';
if (value.startsWith(`${root}/`)) {
return value.slice(root.length);
}
return value;
}

function walk(value: unknown, root: string): unknown {
if (Array.isArray(value)) {
let changed = false;
const out: unknown[] = [];
for (let index = 0; index < value.length; index += 1) {
const item = value[index];
const next = walk(item, root);
if (next !== item) changed = true;
out.push(next);
}
return changed ? out : value;
}

if (isPlainObject(value)) {
let changed = false;
const out: Record<string, unknown> = {};
for (const key of Object.keys(value)) {
const fieldValue = value[key];
const nextValue =
NORMALIZED_URL_FIELDS.has(key) && typeof fieldValue === 'string'
? normalizeBackendUrlString(fieldValue, { applicationRoot: root })
: walk(fieldValue, root);
if (nextValue !== fieldValue) changed = true;
out[key] = nextValue;
}
return changed ? out : value;
}

return value;
}

/**
* Recursively normalise URL fields in a JSON-shaped value. Returns the input
* by reference when nothing changed, so callers can compare with `===`.
*/
export function normalizeBackendUrls<T>(
value: T,
options: NormalizeOptions,
): T {
const root = stripTrailingSlash(options.applicationRoot);
if (!root) return value;
return walk(value, root) as T;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* 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 { SupersetClientClass } from '@superset-ui/core';

// SupersetClient is expected to apply the configured appRoot exactly once.
// Callers must pass router-relative endpoints; pre-prefixing causes the
// double-prefix bug documented below.

describe('SupersetClient applies the application root exactly once', () => {
const buildClient = () =>
new SupersetClientClass({
protocol: 'https:',
host: 'config_host',
appRoot: '/superset',
});

test('endpoint without leading slash is concatenated correctly', () => {
expect(buildClient().getUrl({ endpoint: 'api/v1/chart' })).toBe(
'https://config_host/superset/api/v1/chart',
);
});

test('endpoint with leading slash is normalised to a single root segment', () => {
expect(buildClient().getUrl({ endpoint: '/api/v1/chart' })).toBe(
'https://config_host/superset/api/v1/chart',
);
});

// Documents the double-prefix symptom: wrapping the endpoint in
// ensureAppRoot before passing it to SupersetClient duplicates the root.
// navigationUtils.invariants.test.ts catches this pattern statically.
test('does not double-apply the application root when caller pre-prefixes', () => {
expect(buildClient().getUrl({ endpoint: '/superset/api/v1/chart' })).toBe(
'https://config_host/superset/superset/api/v1/chart',
);
});

test('empty application root produces no prefix segment', () => {
const client = new SupersetClientClass({
protocol: 'https:',
host: 'config_host',
});
expect(client.getUrl({ endpoint: '/api/v1/chart' })).toBe(
'https://config_host/api/v1/chart',
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/**
* 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 {
normalizeBackendUrls,
normalizeBackendUrlString,
NORMALIZED_URL_FIELDS,
} from '../../src/connection/normalizeBackendUrls';

const PREFIX = '/superset';

describe('normalizeBackendUrls', () => {
test('strips application root from a recognised URL field', () => {
const input = { id: 1, explore_url: '/superset/explore/?slice_id=1' };
const output = normalizeBackendUrls(input, { applicationRoot: PREFIX });
expect(output).toEqual({ id: 1, explore_url: '/explore/?slice_id=1' });
});

// The negative cases below prove the normaliser is conservative: it doesn't
// mutate user content, external URLs, or path segments that merely share
// text with the configured root.
test('leaves non-allow-listed fields untouched even when path-shaped', () => {
const input = { description: '/superset/just-text-from-a-user' };
expect(normalizeBackendUrls(input, { applicationRoot: PREFIX })).toEqual(
input,
);
});

test('leaves absolute URLs untouched in recognised fields', () => {
const input = { explore_url: 'https://other.example.com/superset/foo' };
expect(normalizeBackendUrls(input, { applicationRoot: PREFIX })).toEqual(
input,
);
});

test('leaves protocol-relative URLs untouched', () => {
const input = { explore_url: '//cdn.example.com/superset/foo' };
expect(normalizeBackendUrls(input, { applicationRoot: PREFIX })).toEqual(
input,
);
});

test('does not strip a similar-but-different prefix segment', () => {
// /superset-public/... shares text with /superset but is a different path
// segment. Only /superset followed by / or end-of-string counts.
const input = { explore_url: '/superset-public/explore/?slice_id=1' };
expect(normalizeBackendUrls(input, { applicationRoot: PREFIX })).toEqual(
input,
);
});

test('is a no-op when application root is empty', () => {
const input = { explore_url: '/explore/?slice_id=1' };
expect(normalizeBackendUrls(input, { applicationRoot: '' })).toEqual(input);
});
});

describe('normalizeBackendUrlString', () => {
test('strips application root from a router-relative path', () => {
expect(
normalizeBackendUrlString('/superset/sqllab', {
applicationRoot: PREFIX,
}),
).toBe('/sqllab');
});

test('passes absolute URLs through unchanged', () => {
expect(
normalizeBackendUrlString('https://external.example.com/foo', {
applicationRoot: PREFIX,
}),
).toBe('https://external.example.com/foo');
});
});

test('NORMALIZED_URL_FIELDS is a Set for O(1) lookup', () => {
expect(NORMALIZED_URL_FIELDS).toBeInstanceOf(Set);
});

describe('normalizeBackendUrls (recursion + identity)', () => {
test('descends into arrays and normalises matching fields per element', () => {
const input = [
{ explore_url: '/superset/explore/?id=1' },
{ explore_url: '/superset/explore/?id=2' },
];
expect(normalizeBackendUrls(input, { applicationRoot: PREFIX })).toEqual([
{ explore_url: '/explore/?id=1' },
{ explore_url: '/explore/?id=2' },
]);
});

test('descends into nested objects', () => {
const input = {
result: { chart: { explore_url: '/superset/explore/?id=1' } },
};
expect(normalizeBackendUrls(input, { applicationRoot: PREFIX })).toEqual({
result: { chart: { explore_url: '/explore/?id=1' } },
});
});

test('returns input by reference when nothing changed', () => {
const input = { explore_url: '/explore/?id=1' };
const output = normalizeBackendUrls(input, { applicationRoot: PREFIX });
expect(output).toBe(input);
});

test('is idempotent: normalize(normalize(x)) === normalize(x)', () => {
const input = { explore_url: '/superset/explore/?id=1' };
const once = normalizeBackendUrls(input, { applicationRoot: PREFIX });
const twice = normalizeBackendUrls(once, { applicationRoot: PREFIX });
expect(twice).toEqual(once);
});

test('strips a value that equals the application root exactly', () => {
expect(
normalizeBackendUrlString('/superset', { applicationRoot: PREFIX }),
).toBe('/');
});

test('tolerates a trailing slash on applicationRoot', () => {
expect(
normalizeBackendUrlString('/superset/foo', {
applicationRoot: '/superset/',
}),
).toBe('/foo');
});

test('does not descend into class instances (Date, Map)', () => {
const date = new Date('2026-01-01');
const input = { created_at: date };
const output = normalizeBackendUrls(input, { applicationRoot: PREFIX });
expect(output.created_at).toBe(date);
});
});
Loading
Loading