mirror of
https://github.com/apache/superset.git
synced 2026-05-16 05:15:16 +00:00
Compare commits
6 Commits
fix/dashbo
...
embedded-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b62bf0935 | ||
|
|
dc136e8898 | ||
|
|
fdf8525c5d | ||
|
|
f7b5de25e9 | ||
|
|
918143fe90 | ||
|
|
335a08a81b |
9
.github/workflows/bashlib.sh
vendored
9
.github/workflows/bashlib.sh
vendored
@@ -59,6 +59,15 @@ build-assets() {
|
|||||||
say "::endgroup::"
|
say "::endgroup::"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
build-embedded-sdk() {
|
||||||
|
cd "$GITHUB_WORKSPACE/superset-embedded-sdk"
|
||||||
|
|
||||||
|
say "::group::Build embedded SDK bundle for E2E tests"
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
say "::endgroup::"
|
||||||
|
}
|
||||||
|
|
||||||
build-instrumented-assets() {
|
build-instrumented-assets() {
|
||||||
cd "$GITHUB_WORKSPACE/superset-frontend"
|
cd "$GITHUB_WORKSPACE/superset-frontend"
|
||||||
|
|
||||||
|
|||||||
6
.github/workflows/superset-e2e.yml
vendored
6
.github/workflows/superset-e2e.yml
vendored
@@ -169,6 +169,7 @@ jobs:
|
|||||||
PYTHONPATH: ${{ github.workspace }}
|
PYTHONPATH: ${{ github.workspace }}
|
||||||
REDIS_PORT: 16379
|
REDIS_PORT: 16379
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
SUPERSET_FEATURE_EMBEDDED_SUPERSET: "true"
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
@@ -239,6 +240,11 @@ jobs:
|
|||||||
uses: ./.github/actions/cached-dependencies
|
uses: ./.github/actions/cached-dependencies
|
||||||
with:
|
with:
|
||||||
run: build-instrumented-assets
|
run: build-instrumented-assets
|
||||||
|
- name: Build embedded SDK
|
||||||
|
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||||
|
uses: ./.github/actions/cached-dependencies
|
||||||
|
with:
|
||||||
|
run: build-embedded-sdk
|
||||||
- name: Install Playwright
|
- name: Install Playwright
|
||||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||||
uses: ./.github/actions/cached-dependencies
|
uses: ./.github/actions/cached-dependencies
|
||||||
|
|||||||
6
.github/workflows/superset-playwright.yml
vendored
6
.github/workflows/superset-playwright.yml
vendored
@@ -43,6 +43,7 @@ jobs:
|
|||||||
PYTHONPATH: ${{ github.workspace }}
|
PYTHONPATH: ${{ github.workspace }}
|
||||||
REDIS_PORT: 16379
|
REDIS_PORT: 16379
|
||||||
GITHUB_TOKEN: ${{ github.token }}
|
GITHUB_TOKEN: ${{ github.token }}
|
||||||
|
SUPERSET_FEATURE_EMBEDDED_SUPERSET: "true"
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
@@ -113,6 +114,11 @@ jobs:
|
|||||||
uses: ./.github/actions/cached-dependencies
|
uses: ./.github/actions/cached-dependencies
|
||||||
with:
|
with:
|
||||||
run: build-instrumented-assets
|
run: build-instrumented-assets
|
||||||
|
- name: Build embedded SDK
|
||||||
|
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||||
|
uses: ./.github/actions/cached-dependencies
|
||||||
|
with:
|
||||||
|
run: build-embedded-sdk
|
||||||
- name: Install Playwright
|
- name: Install Playwright
|
||||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||||
uses: ./.github/actions/cached-dependencies
|
uses: ./.github/actions/cached-dependencies
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
# Configuration for docker-compose-light.yml - disables Redis and uses minimal services
|
# Configuration for docker-compose-light.yml - disables Redis and uses minimal services
|
||||||
|
|
||||||
# Import all settings from the main config first
|
# Import all settings from the main config first
|
||||||
|
import os
|
||||||
|
|
||||||
from flask_caching.backends.filesystemcache import FileSystemCache
|
from flask_caching.backends.filesystemcache import FileSystemCache
|
||||||
|
|
||||||
from superset_config import * # noqa: F403
|
from superset_config import * # noqa: F403
|
||||||
@@ -36,3 +38,31 @@ THUMBNAIL_CACHE_CONFIG = CACHE_CONFIG
|
|||||||
|
|
||||||
# Disable Celery entirely for lightweight mode
|
# Disable Celery entirely for lightweight mode
|
||||||
CELERY_CONFIG = None # type: ignore[assignment,misc]
|
CELERY_CONFIG = None # type: ignore[assignment,misc]
|
||||||
|
|
||||||
|
# Honor SUPERSET_FEATURE_<NAME> env vars on top of any flags inherited from
|
||||||
|
# superset_config. Lets local dev/e2e enable features (e.g. EMBEDDED_SUPERSET)
|
||||||
|
# without editing shipped config files. Only the literal string "true"
|
||||||
|
# (case-insensitive) is treated as enabled — "1"/"yes"/"on" are not, matching
|
||||||
|
# the strict-string convention used elsewhere in Superset's env parsing.
|
||||||
|
FEATURE_FLAGS = {
|
||||||
|
**FEATURE_FLAGS, # noqa: F405
|
||||||
|
**{
|
||||||
|
name[len("SUPERSET_FEATURE_") :]: value.strip().lower() == "true"
|
||||||
|
for name, value in os.environ.items()
|
||||||
|
if name.startswith("SUPERSET_FEATURE_")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Disable Talisman so /embedded/<uuid> doesn't return X-Frame-Options:SAMEORIGIN.
|
||||||
|
# Without this, browsers refuse to render Superset inside an iframe from a
|
||||||
|
# different origin (i.e. the embedded SDK use case). Production/CI configures
|
||||||
|
# Talisman with explicit `frame-ancestors`; for the lightweight local stack we
|
||||||
|
# just turn it off.
|
||||||
|
TALISMAN_ENABLED = False
|
||||||
|
|
||||||
|
# Guest tokens (used by the embedded SDK) inherit the "Public" role's perms.
|
||||||
|
# Out of the box Public has zero perms, so embedded dashboards immediately fail
|
||||||
|
# their first call (`/api/v1/me/roles/`) with 403. Mirror Public to Gamma —
|
||||||
|
# the standard read-only viewer role — so the embedded flow can authenticate
|
||||||
|
# and load dashboard data in local dev.
|
||||||
|
PUBLIC_ROLE_LIKE = "Gamma"
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ export default defineConfig({
|
|||||||
testIgnore: [
|
testIgnore: [
|
||||||
'**/tests/auth/**/*.spec.ts',
|
'**/tests/auth/**/*.spec.ts',
|
||||||
'**/tests/sqllab/**/*.spec.ts',
|
'**/tests/sqllab/**/*.spec.ts',
|
||||||
|
'**/tests/embedded/**/*.spec.ts',
|
||||||
...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']),
|
...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']),
|
||||||
],
|
],
|
||||||
use: {
|
use: {
|
||||||
@@ -132,6 +133,22 @@ export default defineConfig({
|
|||||||
// No storageState = clean browser with no cached cookies
|
// No storageState = clean browser with no cached cookies
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Embedded dashboard tests - validates the full embedding flow:
|
||||||
|
// external app -> SDK -> iframe -> guest token -> dashboard render.
|
||||||
|
// Each spec file mutates per-dashboard embedding state (UUID,
|
||||||
|
// allowed_domains) on a single shared Superset, so files must not
|
||||||
|
// run in parallel even if more are added later.
|
||||||
|
name: 'chromium-embedded',
|
||||||
|
testMatch: '**/tests/embedded/**/*.spec.ts',
|
||||||
|
fullyParallel: false,
|
||||||
|
use: {
|
||||||
|
browserName: 'chromium',
|
||||||
|
testIdAttribute: 'data-test',
|
||||||
|
// Uses admin auth for API calls to configure embedding and get guest tokens
|
||||||
|
storageState: 'playwright/.auth/user.json',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
// Web server setup - disabled in CI (Flask started separately in workflow)
|
// Web server setup - disabled in CI (Flask started separately in workflow)
|
||||||
|
|||||||
96
superset-frontend/playwright/embedded-app/index.html
Normal file
96
superset-frontend/playwright/embedded-app/index.html
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<!--
|
||||||
|
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.
|
||||||
|
-->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Embedded Dashboard Test App</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; padding: 0; height: 100%; }
|
||||||
|
#superset-container { width: 100%; height: 100vh; }
|
||||||
|
#superset-container iframe { width: 100%; height: 100%; border: none; }
|
||||||
|
#error { color: red; padding: 20px; display: none; }
|
||||||
|
#status { padding: 10px; font-family: monospace; font-size: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="status">Initializing embedded dashboard...</div>
|
||||||
|
<div id="error"></div>
|
||||||
|
<div id="superset-container" data-test="embedded-container"></div>
|
||||||
|
|
||||||
|
<script src="/sdk/index.js"></script>
|
||||||
|
<script>
|
||||||
|
(async function () {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const uuid = params.get('uuid');
|
||||||
|
const supersetDomain = params.get('supersetDomain');
|
||||||
|
|
||||||
|
if (!uuid || !supersetDomain) {
|
||||||
|
document.getElementById('error').style.display = 'block';
|
||||||
|
document.getElementById('error').textContent =
|
||||||
|
'Missing required query params: uuid, supersetDomain';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
|
||||||
|
// fetchGuestToken is injected by Playwright via page.exposeFunction()
|
||||||
|
// Falls back to window.__guestToken for simple/static token injection
|
||||||
|
async function fetchGuestToken() {
|
||||||
|
if (typeof window.__fetchGuestToken === 'function') {
|
||||||
|
statusEl.textContent = 'Fetching guest token...';
|
||||||
|
const token = await window.__fetchGuestToken();
|
||||||
|
statusEl.textContent = 'Guest token received, loading dashboard...';
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
if (window.__guestToken) {
|
||||||
|
return window.__guestToken;
|
||||||
|
}
|
||||||
|
throw new Error('No guest token source available');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse optional UI config from query params
|
||||||
|
const uiConfig = {};
|
||||||
|
if (params.get('hideTitle') === 'true') uiConfig.hideTitle = true;
|
||||||
|
if (params.get('hideTab') === 'true') uiConfig.hideTab = true;
|
||||||
|
if (params.get('hideChartControls') === 'true') uiConfig.hideChartControls = true;
|
||||||
|
|
||||||
|
const dashboard = await supersetEmbeddedSdk.embedDashboard({
|
||||||
|
id: uuid,
|
||||||
|
supersetDomain: supersetDomain,
|
||||||
|
mountPoint: document.getElementById('superset-container'),
|
||||||
|
fetchGuestToken: fetchGuestToken,
|
||||||
|
dashboardUiConfig: Object.keys(uiConfig).length > 0 ? uiConfig : undefined,
|
||||||
|
debug: params.get('debug') === 'true',
|
||||||
|
});
|
||||||
|
|
||||||
|
statusEl.textContent = 'Dashboard embedded successfully';
|
||||||
|
// Expose dashboard API on window for Playwright assertions
|
||||||
|
window.__embeddedDashboard = dashboard;
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('error').style.display = 'block';
|
||||||
|
document.getElementById('error').textContent = 'Embed failed: ' + err.message;
|
||||||
|
statusEl.textContent = 'Error';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -132,26 +132,14 @@ export interface DashboardResult {
|
|||||||
published?: boolean;
|
published?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async function getDashboardByFilter(
|
||||||
* Get a dashboard by its title
|
|
||||||
* @param page - Playwright page instance (provides authentication context)
|
|
||||||
* @param title - The dashboard_title to search for
|
|
||||||
* @returns Dashboard object if found, null if not found
|
|
||||||
*/
|
|
||||||
export async function getDashboardByName(
|
|
||||||
page: Page,
|
page: Page,
|
||||||
title: string,
|
col: 'dashboard_title' | 'slug',
|
||||||
|
value: string,
|
||||||
): Promise<DashboardResult | null> {
|
): Promise<DashboardResult | null> {
|
||||||
const filter = {
|
const queryParam = rison.encode({
|
||||||
filters: [
|
filters: [{ col, opr: 'eq', value }],
|
||||||
{
|
});
|
||||||
col: 'dashboard_title',
|
|
||||||
opr: 'eq',
|
|
||||||
value: title,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const queryParam = rison.encode(filter);
|
|
||||||
const response = await apiGet(
|
const response = await apiGet(
|
||||||
page,
|
page,
|
||||||
`${ENDPOINTS.DASHBOARD}?q=${queryParam}`,
|
`${ENDPOINTS.DASHBOARD}?q=${queryParam}`,
|
||||||
@@ -169,3 +157,29 @@ export async function getDashboardByName(
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a dashboard by its title
|
||||||
|
* @param page - Playwright page instance (provides authentication context)
|
||||||
|
* @param title - The dashboard_title to search for
|
||||||
|
* @returns Dashboard object if found, null if not found
|
||||||
|
*/
|
||||||
|
export async function getDashboardByName(
|
||||||
|
page: Page,
|
||||||
|
title: string,
|
||||||
|
): Promise<DashboardResult | null> {
|
||||||
|
return getDashboardByFilter(page, 'dashboard_title', title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a dashboard by its slug
|
||||||
|
* @param page - Playwright page instance (provides authentication context)
|
||||||
|
* @param slug - The slug to search for
|
||||||
|
* @returns Dashboard object if found, null if not found
|
||||||
|
*/
|
||||||
|
export async function getDashboardBySlug(
|
||||||
|
page: Page,
|
||||||
|
slug: string,
|
||||||
|
): Promise<DashboardResult | null> {
|
||||||
|
return getDashboardByFilter(page, 'slug', slug);
|
||||||
|
}
|
||||||
|
|||||||
136
superset-frontend/playwright/helpers/api/embedded.ts
Normal file
136
superset-frontend/playwright/helpers/api/embedded.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Page } from '@playwright/test';
|
||||||
|
import { apiPost, apiPut, getCsrfToken } from './requests';
|
||||||
|
import { ENDPOINTS as DASHBOARD_ENDPOINTS } from './dashboard';
|
||||||
|
|
||||||
|
export const ENDPOINTS = {
|
||||||
|
SECURITY_LOGIN: 'api/v1/security/login',
|
||||||
|
GUEST_TOKEN: 'api/v1/security/guest_token/',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface EmbeddedConfig {
|
||||||
|
uuid: string;
|
||||||
|
allowed_domains: string[];
|
||||||
|
dashboard_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable embedding on a dashboard and return the embedded UUID.
|
||||||
|
* Uses PUT (upsert) to preserve UUID across repeated calls.
|
||||||
|
* @param page - Playwright page instance (provides authentication context)
|
||||||
|
* @param dashboardIdOrSlug - Numeric dashboard id or slug
|
||||||
|
* @param allowedDomains - Domains allowed to embed; empty array allows all
|
||||||
|
* @returns Embedded config with UUID, allowed_domains, and dashboard_id
|
||||||
|
*/
|
||||||
|
export async function apiEnableEmbedding(
|
||||||
|
page: Page,
|
||||||
|
dashboardIdOrSlug: number | string,
|
||||||
|
allowedDomains: string[] = [],
|
||||||
|
): Promise<EmbeddedConfig> {
|
||||||
|
const response = await apiPut(
|
||||||
|
page,
|
||||||
|
`${DASHBOARD_ENDPOINTS.DASHBOARD}${dashboardIdOrSlug}/embedded`,
|
||||||
|
{ allowed_domains: allowedDomains },
|
||||||
|
);
|
||||||
|
const body = await response.json();
|
||||||
|
return body.result as EmbeddedConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login as admin and return the JWT access token used by the guest_token
|
||||||
|
* endpoint. Cache the result at suite level (`beforeAll`) and pass it into
|
||||||
|
* `getGuestToken` to avoid a fresh login on every test.
|
||||||
|
*
|
||||||
|
* Defaults match `playwright/global-setup.ts` so credentials come from one
|
||||||
|
* source (env vars). Overrides via `options` win.
|
||||||
|
*/
|
||||||
|
export async function getAccessToken(
|
||||||
|
page: Page,
|
||||||
|
options?: { username?: string; password?: string },
|
||||||
|
): Promise<string> {
|
||||||
|
const username =
|
||||||
|
options?.username ?? process.env.PLAYWRIGHT_ADMIN_USERNAME ?? 'admin';
|
||||||
|
const password =
|
||||||
|
options?.password ?? process.env.PLAYWRIGHT_ADMIN_PASSWORD ?? 'general';
|
||||||
|
const loginResponse = await apiPost(
|
||||||
|
page,
|
||||||
|
ENDPOINTS.SECURITY_LOGIN,
|
||||||
|
{
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
provider: 'db',
|
||||||
|
refresh: true,
|
||||||
|
},
|
||||||
|
{ allowMissingCsrf: true },
|
||||||
|
);
|
||||||
|
const loginBody = await loginResponse.json();
|
||||||
|
return loginBody.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a guest token for an embedded dashboard.
|
||||||
|
* If `accessToken` is provided, the login round-trip is skipped — preferred
|
||||||
|
* for tests that fetch many tokens from a single suite.
|
||||||
|
* @returns Signed guest token string
|
||||||
|
*/
|
||||||
|
export async function getGuestToken(
|
||||||
|
page: Page,
|
||||||
|
dashboardId: number | string,
|
||||||
|
options?: {
|
||||||
|
accessToken?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
rls?: Array<{ dataset: number; clause: string }>;
|
||||||
|
},
|
||||||
|
): Promise<string> {
|
||||||
|
const accessToken =
|
||||||
|
options?.accessToken ??
|
||||||
|
(await getAccessToken(page, {
|
||||||
|
username: options?.username,
|
||||||
|
password: options?.password,
|
||||||
|
}));
|
||||||
|
const rls = options?.rls ?? [];
|
||||||
|
|
||||||
|
// The guest_token endpoint authenticates via JWT Bearer, but if the
|
||||||
|
// request also carries a session cookie (which page.request inherits from
|
||||||
|
// storageState), Flask-WTF still requires a matching X-CSRFToken. Send it
|
||||||
|
// unconditionally so this works whether the caller is authenticated via
|
||||||
|
// session, JWT, or both.
|
||||||
|
const { token: csrfToken } = await getCsrfToken(page);
|
||||||
|
const guestResponse = await page.request.post(ENDPOINTS.GUEST_TOKEN, {
|
||||||
|
data: {
|
||||||
|
user: {
|
||||||
|
username: 'embedded_test_user',
|
||||||
|
first_name: 'Embedded',
|
||||||
|
last_name: 'TestUser',
|
||||||
|
},
|
||||||
|
resources: [{ type: 'dashboard', id: String(dashboardId) }],
|
||||||
|
rls,
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
...(csrfToken ? { 'X-CSRFToken': csrfToken } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const guestBody = await guestResponse.json();
|
||||||
|
return guestBody.token;
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ function getBaseUrl(): string {
|
|||||||
return url.endsWith('/') ? url : `${url}/`;
|
return url.endsWith('/') ? url : `${url}/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CsrfResult {
|
export interface CsrfResult {
|
||||||
token: string;
|
token: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ interface CsrfResult {
|
|||||||
* Superset provides a CSRF token via api/v1/security/csrf_token/
|
* Superset provides a CSRF token via api/v1/security/csrf_token/
|
||||||
* The session cookie is automatically included by page.request
|
* The session cookie is automatically included by page.request
|
||||||
*/
|
*/
|
||||||
async function getCsrfToken(page: Page): Promise<CsrfResult> {
|
export async function getCsrfToken(page: Page): Promise<CsrfResult> {
|
||||||
try {
|
try {
|
||||||
const response = await page.request.get('api/v1/security/csrf_token/', {
|
const response = await page.request.get('api/v1/security/csrf_token/', {
|
||||||
failOnStatusCode: false,
|
failOnStatusCode: false,
|
||||||
|
|||||||
172
superset-frontend/playwright/pages/EmbeddedPage.ts
Normal file
172
superset-frontend/playwright/pages/EmbeddedPage.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* 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 { Page, FrameLocator, Locator, expect } from '@playwright/test';
|
||||||
|
import { EMBEDDED } from '../utils/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page object for the embedded dashboard test app.
|
||||||
|
*
|
||||||
|
* The test app runs on a separate origin (its origin is assigned per-suite
|
||||||
|
* via an OS-allocated port) and uses the @superset-ui/embedded-sdk to render
|
||||||
|
* a Superset dashboard in an iframe. Playwright's page.exposeFunction()
|
||||||
|
* bridges the guest token from Node.js into the browser page.
|
||||||
|
*/
|
||||||
|
export class EmbeddedPage {
|
||||||
|
private readonly page: Page;
|
||||||
|
|
||||||
|
private static readonly SELECTORS = {
|
||||||
|
CONTAINER: '[data-test="embedded-container"]',
|
||||||
|
IFRAME: 'iframe[title="Embedded Dashboard"]',
|
||||||
|
STATUS: '#status',
|
||||||
|
ERROR: '#error',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up the guest token bridge before navigating.
|
||||||
|
* Must be called BEFORE goto() since embedDashboard() calls fetchGuestToken
|
||||||
|
* immediately on page load.
|
||||||
|
*/
|
||||||
|
async exposeTokenFetcher(tokenFn: () => Promise<string>): Promise<void> {
|
||||||
|
await this.page.exposeFunction('__fetchGuestToken', tokenFn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the embedded test app with the given parameters.
|
||||||
|
* `appUrl` is the origin of the static test app (assigned dynamically by
|
||||||
|
* the spec's beforeAll fixture so workers don't collide on a fixed port).
|
||||||
|
*/
|
||||||
|
async goto(params: {
|
||||||
|
appUrl: string;
|
||||||
|
uuid: string;
|
||||||
|
supersetDomain: string;
|
||||||
|
hideTitle?: boolean;
|
||||||
|
hideTab?: boolean;
|
||||||
|
hideChartControls?: boolean;
|
||||||
|
debug?: boolean;
|
||||||
|
}): Promise<void> {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
uuid: params.uuid,
|
||||||
|
supersetDomain: params.supersetDomain,
|
||||||
|
});
|
||||||
|
if (params.hideTitle) searchParams.set('hideTitle', 'true');
|
||||||
|
if (params.hideTab) searchParams.set('hideTab', 'true');
|
||||||
|
if (params.hideChartControls) searchParams.set('hideChartControls', 'true');
|
||||||
|
if (params.debug) searchParams.set('debug', 'true');
|
||||||
|
|
||||||
|
await this.page.goto(`${params.appUrl}/?${searchParams.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FrameLocator for the embedded dashboard iframe.
|
||||||
|
*/
|
||||||
|
get iframe(): FrameLocator {
|
||||||
|
return this.page.frameLocator(EmbeddedPage.SELECTORS.IFRAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the iframe to appear in the DOM AND have its src set.
|
||||||
|
* The SDK appends the iframe element before assigning src, so a bare
|
||||||
|
* `state: 'attached'` wait races the src read.
|
||||||
|
*/
|
||||||
|
async waitForIframe(options?: { timeout?: number }): Promise<void> {
|
||||||
|
const locator = this.page.locator(EmbeddedPage.SELECTORS.IFRAME);
|
||||||
|
await locator.waitFor({
|
||||||
|
state: 'attached',
|
||||||
|
timeout: options?.timeout ?? EMBEDDED.IFRAME_LOAD,
|
||||||
|
});
|
||||||
|
await expect(locator).toHaveAttribute('src', /.+/, {
|
||||||
|
timeout: options?.timeout ?? EMBEDDED.IFRAME_LOAD,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for dashboard content to render inside the iframe.
|
||||||
|
* Looks for the grid-container which indicates charts are loading/loaded.
|
||||||
|
*/
|
||||||
|
async waitForDashboardContent(options?: { timeout?: number }): Promise<void> {
|
||||||
|
const frame = this.iframe;
|
||||||
|
await frame
|
||||||
|
.locator('.grid-container, [data-test="grid-container"]')
|
||||||
|
.first()
|
||||||
|
.waitFor({
|
||||||
|
state: 'visible',
|
||||||
|
timeout: options?.timeout ?? EMBEDDED.DASHBOARD_RENDER,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a chart cell that has finished loading: it contains a real viz
|
||||||
|
* element (svg, canvas, table) AND no longer hosts the `Loading` spinner
|
||||||
|
* (`data-test="loading-indicator"`). Excluding the spinner matters —
|
||||||
|
* the spinner itself renders an SVG, so a `:has(svg)`-only check can match
|
||||||
|
* a still-loading chart for the wrong reason.
|
||||||
|
*/
|
||||||
|
static readonly RENDERED_CHART_SELECTOR =
|
||||||
|
'[data-test="chart-container"]:has(svg, canvas, table):not(:has([data-test="loading-indicator"]))';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for at least one chart to finish rendering — viz drawn AND no
|
||||||
|
* loading spinner in that cell.
|
||||||
|
*/
|
||||||
|
async waitForChartRendered(options?: { timeout?: number }): Promise<void> {
|
||||||
|
await this.iframe
|
||||||
|
.locator(EmbeddedPage.RENDERED_CHART_SELECTOR)
|
||||||
|
.first()
|
||||||
|
.waitFor({
|
||||||
|
state: 'visible',
|
||||||
|
timeout: options?.timeout ?? EMBEDDED.CHART_RENDER,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locator for the dashboard title input inside the iframe.
|
||||||
|
* Returned as a `Locator` so callers can use `expect(...).toBeVisible()` /
|
||||||
|
* `.toBeHidden()` with auto-retry instead of one-shot `.isVisible()`.
|
||||||
|
*/
|
||||||
|
get titleLocator(): Locator {
|
||||||
|
return this.iframe.locator(
|
||||||
|
'[data-test="dashboard-header-container"] [data-test="editable-title-input"]',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the status text from the test app.
|
||||||
|
*/
|
||||||
|
async getStatus(): Promise<string> {
|
||||||
|
return (
|
||||||
|
(await this.page.locator(EmbeddedPage.SELECTORS.STATUS).textContent()) ??
|
||||||
|
''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the error text, if any.
|
||||||
|
*/
|
||||||
|
async getError(): Promise<string> {
|
||||||
|
const errorEl = this.page.locator(EmbeddedPage.SELECTORS.ERROR);
|
||||||
|
const display = await errorEl.evaluate(el => getComputedStyle(el).display);
|
||||||
|
if (display === 'none') return '';
|
||||||
|
return (await errorEl.textContent()) ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
/**
|
||||||
|
* 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 { test, expect, Browser, BrowserContext, Page } from '@playwright/test';
|
||||||
|
import { createServer, IncomingMessage, ServerResponse, Server } from 'http';
|
||||||
|
import { AddressInfo, Socket } from 'net';
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import {
|
||||||
|
apiEnableEmbedding,
|
||||||
|
getAccessToken,
|
||||||
|
getGuestToken,
|
||||||
|
} from '../../helpers/api/embedded';
|
||||||
|
import { getDashboardBySlug } from '../../helpers/api/dashboard';
|
||||||
|
import { EmbeddedPage } from '../../pages/EmbeddedPage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Superset domain (Flask server) — set by CI or defaults to local dev
|
||||||
|
*/
|
||||||
|
const SUPERSET_DOMAIN = (() => {
|
||||||
|
const url = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
|
||||||
|
return url.replace(/\/+$/, '');
|
||||||
|
})();
|
||||||
|
|
||||||
|
const SUPERSET_BASE_URL = SUPERSET_DOMAIN.endsWith('/')
|
||||||
|
? SUPERSET_DOMAIN
|
||||||
|
: `${SUPERSET_DOMAIN}/`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to the SDK bundle built from superset-embedded-sdk/
|
||||||
|
*/
|
||||||
|
const SDK_BUNDLE_PATH = join(
|
||||||
|
__dirname,
|
||||||
|
'../../../../superset-embedded-sdk/bundle/index.js',
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path to the embedded test app static files
|
||||||
|
*/
|
||||||
|
const EMBED_APP_DIR = join(__dirname, '../../embedded-app');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a minimal static file server for the embedded test app.
|
||||||
|
* Serves only a fixed allowlist of routes — the test app references just
|
||||||
|
* its index.html and the SDK bundle, so anything else is 404.
|
||||||
|
*/
|
||||||
|
const INDEX_HTML_PATH = join(EMBED_APP_DIR, 'index.html');
|
||||||
|
|
||||||
|
interface EmbedAppServer {
|
||||||
|
server: Server;
|
||||||
|
url: string;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the static test app on an OS-assigned ephemeral port. Tracks open
|
||||||
|
* sockets so close() doesn't hang on iframe keep-alive connections, and so
|
||||||
|
* different workers/retries never collide on a fixed port.
|
||||||
|
*/
|
||||||
|
async function startEmbedAppServer(): Promise<EmbedAppServer> {
|
||||||
|
const sockets = new Set<Socket>();
|
||||||
|
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||||
|
const urlPath = req.url?.split('?')[0] || '/';
|
||||||
|
|
||||||
|
if (urlPath === '/sdk/index.js') {
|
||||||
|
if (!existsSync(SDK_BUNDLE_PATH)) {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end(
|
||||||
|
'SDK bundle not found. Run: cd superset-embedded-sdk && npm ci && npm run build',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/javascript' });
|
||||||
|
res.end(readFileSync(SDK_BUNDLE_PATH));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlPath === '/' || urlPath === '/index.html') {
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end(readFileSync(INDEX_HTML_PATH));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('Not found');
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on('connection', socket => {
|
||||||
|
sockets.add(socket);
|
||||||
|
socket.once('close', () => sockets.delete(socket));
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
server.removeListener('error', reject);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = server.address() as AddressInfo;
|
||||||
|
const url = `http://127.0.0.1:${address.port}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
server,
|
||||||
|
url,
|
||||||
|
close: () =>
|
||||||
|
new Promise<void>(resolve => {
|
||||||
|
for (const socket of sockets) socket.destroy();
|
||||||
|
sockets.clear();
|
||||||
|
server.close(() => resolve());
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a browser context authenticated as admin for API-only work
|
||||||
|
* (enabling embedding, restoring config). Caller is responsible for closing.
|
||||||
|
*/
|
||||||
|
function createAdminContext(browser: Browser): Promise<BrowserContext> {
|
||||||
|
return browser.newContext({
|
||||||
|
storageState: 'playwright/.auth/user.json',
|
||||||
|
baseURL: SUPERSET_BASE_URL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Test Suite ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Describe wrapper is needed for shared server state and serial execution:
|
||||||
|
// all tests share a static file server and must not run in parallel.
|
||||||
|
test.describe('Embedded Dashboard E2E', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
// The full embedded chain (login → guest token → iframe → dashboard render
|
||||||
|
// → chart render) routinely exceeds the 30s default on cold CI starts.
|
||||||
|
test.setTimeout(60000);
|
||||||
|
|
||||||
|
let appServer: EmbedAppServer;
|
||||||
|
let accessToken: string;
|
||||||
|
let embedUuid: string;
|
||||||
|
let dashboardId: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up a page to render the default embedded dashboard.
|
||||||
|
* Tests that need a different UUID or UI config should not use this helper.
|
||||||
|
*/
|
||||||
|
async function setupEmbeddedPage(page: Page): Promise<EmbeddedPage> {
|
||||||
|
const embeddedPage = new EmbeddedPage(page);
|
||||||
|
await embeddedPage.exposeTokenFetcher(async () =>
|
||||||
|
getGuestToken(page, dashboardId, { accessToken }),
|
||||||
|
);
|
||||||
|
await embeddedPage.goto({
|
||||||
|
appUrl: appServer.url,
|
||||||
|
uuid: embedUuid,
|
||||||
|
supersetDomain: SUPERSET_DOMAIN,
|
||||||
|
});
|
||||||
|
await embeddedPage.waitForIframe();
|
||||||
|
await embeddedPage.waitForDashboardContent();
|
||||||
|
return embeddedPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
// Skip all tests if the SDK bundle hasn't been built
|
||||||
|
test.skip(
|
||||||
|
!existsSync(SDK_BUNDLE_PATH),
|
||||||
|
'Embedded SDK bundle not found. Build it with: cd superset-embedded-sdk && npm ci && npm run build',
|
||||||
|
);
|
||||||
|
|
||||||
|
appServer = await startEmbedAppServer();
|
||||||
|
|
||||||
|
// Use a fresh context with auth to set up test data via API
|
||||||
|
const context = await createAdminContext(browser);
|
||||||
|
const setupPage = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dashboard = await getDashboardBySlug(setupPage, 'world_health');
|
||||||
|
if (!dashboard) {
|
||||||
|
throw new Error(
|
||||||
|
'Dashboard "world_health" not found. Ensure load_examples ran in CI setup.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
dashboardId = dashboard.id;
|
||||||
|
|
||||||
|
// Enable embedding on the dashboard (empty allowed_domains = allow all)
|
||||||
|
const embedded = await apiEnableEmbedding(setupPage, dashboardId);
|
||||||
|
embedUuid = embedded.uuid;
|
||||||
|
|
||||||
|
// Cache the JWT access token so tests don't re-login per guest token.
|
||||||
|
accessToken = await getAccessToken(setupPage);
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async ({ browser }) => {
|
||||||
|
// Defensive restore in case the allowed_domains test failed mid-flight.
|
||||||
|
if (dashboardId !== undefined) {
|
||||||
|
const context = await createAdminContext(browser);
|
||||||
|
try {
|
||||||
|
const setupPage = await context.newPage();
|
||||||
|
await apiEnableEmbedding(setupPage, dashboardId, []);
|
||||||
|
} catch {
|
||||||
|
// Best-effort cleanup — never fail teardown.
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appServer) await appServer.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard renders in embedded iframe', async ({ page }) => {
|
||||||
|
const embeddedPage = await setupEmbeddedPage(page);
|
||||||
|
|
||||||
|
// Verify the iframe src points to Superset's /embedded/ endpoint
|
||||||
|
await expect(
|
||||||
|
page.locator('iframe[title="Embedded Dashboard"]'),
|
||||||
|
).toHaveAttribute('src', new RegExp(`/embedded/${embedUuid}`));
|
||||||
|
|
||||||
|
// Verify no errors in the test app
|
||||||
|
expect(await embeddedPage.getError()).toBe('');
|
||||||
|
|
||||||
|
// Baseline: title should be visible when hideTitle is not set. This
|
||||||
|
// doubles as a positive existence check the `hideTitle` test relies on
|
||||||
|
// for distinguishing "title was hidden" from "selector is wrong".
|
||||||
|
await expect(embeddedPage.titleLocator).toBeVisible();
|
||||||
|
|
||||||
|
// Prove the dashboard actually renders, not just the chrome.
|
||||||
|
await embeddedPage.waitForChartRendered();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('UI config hideTitle hides dashboard title', async ({ page }) => {
|
||||||
|
const embeddedPage = new EmbeddedPage(page);
|
||||||
|
await embeddedPage.exposeTokenFetcher(async () =>
|
||||||
|
getGuestToken(page, dashboardId, { accessToken }),
|
||||||
|
);
|
||||||
|
await embeddedPage.goto({
|
||||||
|
appUrl: appServer.url,
|
||||||
|
uuid: embedUuid,
|
||||||
|
supersetDomain: SUPERSET_DOMAIN,
|
||||||
|
hideTitle: true,
|
||||||
|
});
|
||||||
|
await embeddedPage.waitForIframe();
|
||||||
|
await embeddedPage.waitForDashboardContent();
|
||||||
|
|
||||||
|
// The iframe URL should include uiConfig parameter
|
||||||
|
await expect(
|
||||||
|
page.locator('iframe[title="Embedded Dashboard"]'),
|
||||||
|
).toHaveAttribute('src', /uiConfig=/);
|
||||||
|
|
||||||
|
// hideTitle removes the header from the DOM (rather than CSS-hiding it),
|
||||||
|
// so toBeHidden + toHaveCount(0) together assert: not visible AND
|
||||||
|
// confirmed-removed (so the test can't pass for the wrong reason if the
|
||||||
|
// selector ever drifts — the baseline test asserts the selector matches
|
||||||
|
// when hideTitle is off).
|
||||||
|
await expect(embeddedPage.titleLocator).toBeHidden();
|
||||||
|
await expect(embeddedPage.titleLocator).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('charts render inside embedded iframe', async ({ page }) => {
|
||||||
|
const embeddedPage = await setupEmbeddedPage(page);
|
||||||
|
|
||||||
|
await embeddedPage.waitForChartRendered();
|
||||||
|
const renderedCharts = embeddedPage.iframe.locator(
|
||||||
|
EmbeddedPage.RENDERED_CHART_SELECTOR,
|
||||||
|
);
|
||||||
|
expect(await renderedCharts.count()).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allowed_domains blocks unauthorized referrer', async ({
|
||||||
|
page,
|
||||||
|
browser,
|
||||||
|
}) => {
|
||||||
|
const context = await createAdminContext(browser);
|
||||||
|
const setupPage = await context.newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Restrict to a domain that is NOT the test app's origin
|
||||||
|
const restrictedEmbed = await apiEnableEmbedding(setupPage, dashboardId, [
|
||||||
|
'https://allowed.example.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const embeddedPage = new EmbeddedPage(page);
|
||||||
|
await embeddedPage.exposeTokenFetcher(async () =>
|
||||||
|
getGuestToken(page, dashboardId, { accessToken }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The deterministic signal that the referrer check fired is the HTTP
|
||||||
|
// status of the /embedded/<uuid> response — assert that directly rather
|
||||||
|
// than racing against cross-origin iframe rendering.
|
||||||
|
const embeddedResponsePromise = page.waitForResponse(
|
||||||
|
resp =>
|
||||||
|
resp.url().includes(`/embedded/${restrictedEmbed.uuid}`) &&
|
||||||
|
resp.request().resourceType() === 'document',
|
||||||
|
);
|
||||||
|
|
||||||
|
await embeddedPage.goto({
|
||||||
|
appUrl: appServer.url,
|
||||||
|
uuid: restrictedEmbed.uuid,
|
||||||
|
supersetDomain: SUPERSET_DOMAIN,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await embeddedResponsePromise;
|
||||||
|
expect(response.status()).toBe(403);
|
||||||
|
} finally {
|
||||||
|
// Restore the open embedding config for other tests in this file.
|
||||||
|
await apiEnableEmbedding(setupPage, dashboardId, []);
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guest token enables dashboard data access', async ({ page }) => {
|
||||||
|
const embeddedPage = new EmbeddedPage(page);
|
||||||
|
|
||||||
|
let tokenCallCount = 0;
|
||||||
|
await embeddedPage.exposeTokenFetcher(async () => {
|
||||||
|
tokenCallCount += 1;
|
||||||
|
return getGuestToken(page, dashboardId, { accessToken });
|
||||||
|
});
|
||||||
|
|
||||||
|
await embeddedPage.goto({
|
||||||
|
appUrl: appServer.url,
|
||||||
|
uuid: embedUuid,
|
||||||
|
supersetDomain: SUPERSET_DOMAIN,
|
||||||
|
});
|
||||||
|
await embeddedPage.waitForIframe();
|
||||||
|
await embeddedPage.waitForDashboardContent();
|
||||||
|
await embeddedPage.waitForChartRendered();
|
||||||
|
|
||||||
|
// The SDK fetches the token exactly once per embed (caching is the
|
||||||
|
// SDK's responsibility, not ours) — assert the stronger invariant.
|
||||||
|
expect(tokenCallCount).toBe(1);
|
||||||
|
|
||||||
|
// Confirm at least one chart actually rendered with data, not just its shell
|
||||||
|
const renderedCharts = embeddedPage.iframe.locator(
|
||||||
|
EmbeddedPage.RENDERED_CHART_SELECTOR,
|
||||||
|
);
|
||||||
|
expect(await renderedCharts.count()).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -75,3 +75,16 @@ export const TIMEOUT = {
|
|||||||
*/
|
*/
|
||||||
SLOW_TEST: 60000, // 60s for tests that chain multiple slow operations
|
SLOW_TEST: 60000, // 60s for tests that chain multiple slow operations
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embedded dashboard test app configuration.
|
||||||
|
* The test app is served by a Node.js http server started in the test fixture.
|
||||||
|
*/
|
||||||
|
export const EMBEDDED = {
|
||||||
|
/** Timeout for iframe to appear in the DOM */
|
||||||
|
IFRAME_LOAD: 15000, // 15s
|
||||||
|
/** Timeout for dashboard content to render inside the iframe */
|
||||||
|
DASHBOARD_RENDER: 30000, // 30s
|
||||||
|
/** Timeout for individual chart cells to finish rendering */
|
||||||
|
CHART_RENDER: 30000, // 30s
|
||||||
|
} as const;
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ FEATURE_FLAGS = {
|
|||||||
|
|
||||||
WEBDRIVER_BASEURL = "http://0.0.0.0:8081/"
|
WEBDRIVER_BASEURL = "http://0.0.0.0:8081/"
|
||||||
|
|
||||||
|
# Enable CORS for embedded dashboard E2E tests (test app on port 9000)
|
||||||
|
ENABLE_CORS = True
|
||||||
|
CORS_OPTIONS: dict = {
|
||||||
|
"origins": [
|
||||||
|
"http://localhost:9000",
|
||||||
|
],
|
||||||
|
"supports_credentials": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def GET_FEATURE_FLAGS_FUNC(ff): # noqa: N802
|
def GET_FEATURE_FLAGS_FUNC(ff): # noqa: N802
|
||||||
ff_copy = copy(ff)
|
ff_copy = copy(ff)
|
||||||
@@ -86,6 +95,7 @@ def GET_FEATURE_FLAGS_FUNC(ff): # noqa: N802
|
|||||||
|
|
||||||
|
|
||||||
TESTING = True
|
TESTING = True
|
||||||
|
TALISMAN_ENABLED = False
|
||||||
WTF_CSRF_ENABLED = False
|
WTF_CSRF_ENABLED = False
|
||||||
|
|
||||||
FAB_ROLES = {"TestRole": [["Security", "menu_access"], ["List Users", "menu_access"]]}
|
FAB_ROLES = {"TestRole": [["Security", "menu_access"], ["List Users", "menu_access"]]}
|
||||||
|
|||||||
Reference in New Issue
Block a user