Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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,142 @@
/**
* 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.
*/

/**
* Normalises backend-supplied URL fields so the frontend speaks one shape
* (router-relative paths) regardless of whether Superset is deployed at the
* web root or under a subdirectory.
*
* The backend renders absolute paths that include the application root, e.g.
* `/superset/explore/?slice_id=1`. Channel-3 helpers (window.open, redirect,
* AppLink) and channel-2 (`SupersetClient`) re-apply the root themselves;
* leaving the prefix on a backend value would double it. So we strip the
* configured root on the way in and let the consumers re-add it.
*
* # Why this is conservative by design
*
* The normaliser **only touches fields whose name appears in
* `NORMALIZED_URL_FIELDS`**. It does not heuristically detect URLs by content
* — a `description` field containing `/looks/like/a/path` is left alone.
* Adding a new URL field to the backend therefore requires an explicit
* one-line change here. Drift requires intentional opt-in.
*
* Exact-segment prefix matching prevents false positives where a value
* happens to share a prefix with the application root (e.g.
* `/superset-public/...` is not stripped when the root is `/superset`).
*
* Absolute URLs (`https://...`, `mailto:`, protocol-relative `//cdn`) and
* already-router-relative paths are passed through unchanged.
*/

const NOT_IMPLEMENTED =
'normalizeBackendUrls is not implemented yet — landing in the green commit of the subdirectory-helpers PR.';

/**
* Field names whose values are router-relative URLs to this Superset
* deployment and therefore safe to normalise.
*
* Curated, not heuristic. Add a field here only after confirming:
*
* 1. The backend always sets it to a path within this Superset instance
* (never an external URL or a path with a different prefix).
* 2. Every consumer expects to feed the value to a channel-3 helper or
* `SupersetClient`, both of which re-apply the application root.
*
* Fields that have been *deliberately excluded* are listed in
* `NORMALIZER_EXCLUSIONS` below with the reason — keep that list in sync.
*/
export const NORMALIZED_URL_FIELDS = new Set<string>([
// Concrete entries are added in the green commit after the per-endpoint
// audit. The skeleton commit only ships the constant so static-invariant
// tests have a stable import target.
]);

/**
* URL-shaped field names that we have deliberately *not* added to
* `NORMALIZED_URL_FIELDS`, with the reason. The negative tests in
* `normalizeBackendUrls.test.ts` assert that values for these names are
* passed through unchanged even when the value happens to begin with the
* configured application root.
*
* This list is informational — code does not read it. Its purpose is to
* preserve institutional knowledge so a future contributor doesn't add an
* exclusion to the allow-list by mistake.
*/
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, not a Superset route',
},
{ field: 'tracking_url', reason: 'External (analytics)' },
{ field: 'user_login_url', reason: 'OAuth / SSO endpoints, may be external' },
{
field: 'user_logout_url',
reason: 'OAuth / SSO endpoints, may be external',
},
{ field: 'user_info_url', reason: 'OAuth / SSO endpoints, may be external' },
{
field: 'thumbnail_url',
reason:
'Storage host varies (S3 / local) — needs per-endpoint audit before normalising',
},
{
field: 'creator_url',
reason: 'User-profile destination varies by deployment',
},
];

export interface NormalizeOptions {
/**
* Application root to strip. Pass an empty string to disable normalisation.
* Trailing slash is tolerated; the strip logic compares whole path segments.
*/
applicationRoot: string;
}

/**
* Recursively normalise URL fields in a JSON-shaped value.
*
* Returns a new value when normalisation changed anything; otherwise returns
* the input by reference so consumers can compare with `===`.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- stub
export function normalizeBackendUrls<T>(
value: T,
options: NormalizeOptions,
): T {
throw new Error(NOT_IMPLEMENTED);
}

/**
* Normalise a single URL string. Exposed for use cases that read a URL
* directly (e.g. bootstrap data) without going through the recursive walker.
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- stub
export function normalizeBackendUrlString(
value: string,
options: NormalizeOptions,
): string {
throw new Error(NOT_IMPLEMENTED);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* 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';

// =============================================================================
// Layer 4 example: SupersetClient × applicationRoot contract
// =============================================================================
//
// Layer 4 pins down the contract between the channel-2 client and the
// application root. The channel rule is "callers pass router-relative paths;
// the client adds the prefix exactly once." This file proves that property in
// isolation so the rest of the codebase can rely on it.
//
// The full PR adds parallel tests for the React Router channel
// (`<MemoryRouter basename>` × `<Link to>`) and a composition test that
// drives `redirect()` and `<Link>` together. This file ships the
// SupersetClient half as the template.
// =============================================================================

describe('SupersetClient applies the application root exactly once', () => {
test('endpoint without leading slash is concatenated correctly under a non-empty appRoot', () => {
const client = new SupersetClientClass({
protocol: 'https:',
host: 'config_host',
appRoot: '/superset',
});
expect(client.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', () => {
const client = new SupersetClientClass({
protocol: 'https:',
host: 'config_host',
appRoot: '/superset',
});
expect(client.getUrl({ endpoint: '/api/v1/chart' })).toBe(
'https://config_host/superset/api/v1/chart',
);
});

test('does not double-apply the application root when caller pre-prefixes', () => {
// This documents the bug class the helpers protect against. Pre-prefixing
// is forbidden by the channel rule, but we record the current behaviour
// here so anyone debugging a double-prefix issue lands on this assertion
// and reads the comment.
const client = new SupersetClientClass({
protocol: 'https:',
host: 'config_host',
appRoot: '/superset',
});
expect(client.getUrl({ endpoint: '/superset/api/v1/chart' })).toBe(
'https://config_host/superset/superset/api/v1/chart',
);
// ^ The duplicated `/superset` is exactly the symptom developers see when
// they wrap a SupersetClient endpoint in `ensureAppRoot`. The static
// invariant test in `navigationUtils.invariants.test.ts` catches that
// pattern before it reaches runtime.
});

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,116 @@
/**
* 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';

// =============================================================================
// Layer 3 example: backend URL normaliser
// =============================================================================
//
// Layer 3 has two halves: positive tests (the normaliser strips the
// configured root from values in `NORMALIZED_URL_FIELDS`) and negative tests
// (it leaves everything else alone). The negative half carries most of the
// safety value — it's how we prove the normaliser doesn't over-reach.
//
// The full PR adds:
// • Per-field positive tests for every entry in NORMALIZED_URL_FIELDS
// • Per-field negative tests for every entry in NORMALIZER_EXCLUSIONS
// • Recursion through arrays and nested objects
// • Idempotence: `normalize(normalize(x))` equals `normalize(x)`
// • Per-call opt-out hook from SupersetClient
//
// This file ships one positive + one negative test as a template.
// =============================================================================

const PREFIX = '/superset';

describe('normalizeBackendUrls (Layer 3 — positive)', () => {
test('strips configured application root from a recognised URL field', () => {
// `explore_url` will be added to NORMALIZED_URL_FIELDS in the green commit.
// Until then this assertion exists to drive that decision.
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' });
});
});

describe('normalizeBackendUrls (Layer 3 — negative passthrough)', () => {
test('leaves random non-allow-listed fields alone even when value looks path-shaped', () => {
// `description` is not — and must never be — a URL field. A user could
// legitimately type `/looks/like/a/path` into a description; stripping
// the prefix would silently mutate user content.
const input = { description: '/superset/just-text-from-a-user' };
const output = normalizeBackendUrls(input, { applicationRoot: PREFIX });
expect(output).toEqual(input);
});

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

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

test('does not strip a similar-but-different prefix segment', () => {
// `/superset-public/...` shares the `/superset` text but is a different
// path segment. Conservative match: only `/superset` followed by `/` or
// end-of-string is treated as the application root.
const input = { explore_url: '/superset-public/explore/?slice_id=1' };
const output = normalizeBackendUrls(input, { applicationRoot: PREFIX });
expect(output).toEqual(input);
});

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

describe('normalizeBackendUrlString (Layer 3 — string-level entry point)', () => {
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');
});
});

describe('NORMALIZED_URL_FIELDS (allow-list shape)', () => {
test('is a Set so callers can rely on O(1) membership checks', () => {
expect(NORMALIZED_URL_FIELDS).toBeInstanceOf(Set);
});
});
Loading
Loading