diff --git a/superset-frontend/playwright.config.ts b/superset-frontend/playwright.config.ts index 9edef2176c8..da2bdbc1fb2 100644 --- a/superset-frontend/playwright.config.ts +++ b/superset-frontend/playwright.config.ts @@ -95,6 +95,7 @@ export default defineConfig({ testIgnore: [ '**/tests/auth/**/*.spec.ts', '**/tests/sqllab/**/*.spec.ts', + '**/tests/embedded/**/*.spec.ts', ...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']), ], use: { @@ -132,6 +133,18 @@ export default defineConfig({ // No storageState = clean browser with no cached cookies }, }, + { + // Embedded dashboard tests - validates the full embedding flow: + // external app -> SDK -> iframe -> guest token -> dashboard render + name: 'chromium-embedded', + testMatch: '**/tests/embedded/**/*.spec.ts', + 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) diff --git a/superset-frontend/playwright/embedded-app/index.html b/superset-frontend/playwright/embedded-app/index.html new file mode 100644 index 00000000000..ef58a82ded0 --- /dev/null +++ b/superset-frontend/playwright/embedded-app/index.html @@ -0,0 +1,96 @@ + + + + + + + Embedded Dashboard Test App + + + +
Initializing embedded dashboard...
+
+
+ + + + + diff --git a/superset-frontend/playwright/helpers/api/dashboard.ts b/superset-frontend/playwright/helpers/api/dashboard.ts index 2eb8d092c0d..f8141f57c40 100644 --- a/superset-frontend/playwright/helpers/api/dashboard.ts +++ b/superset-frontend/playwright/helpers/api/dashboard.ts @@ -132,26 +132,14 @@ export interface DashboardResult { published?: boolean; } -/** - * 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( +async function getDashboardByFilter( page: Page, - title: string, + col: 'dashboard_title' | 'slug', + value: string, ): Promise { - const filter = { - filters: [ - { - col: 'dashboard_title', - opr: 'eq', - value: title, - }, - ], - }; - const queryParam = rison.encode(filter); + const queryParam = rison.encode({ + filters: [{ col, opr: 'eq', value }], + }); const response = await apiGet( page, `${ENDPOINTS.DASHBOARD}?q=${queryParam}`, @@ -169,3 +157,29 @@ export async function getDashboardByName( 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 { + 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 { + return getDashboardByFilter(page, 'slug', slug); +} diff --git a/superset-frontend/playwright/helpers/api/embedded.ts b/superset-frontend/playwright/helpers/api/embedded.ts new file mode 100644 index 00000000000..e9e40ffb4c5 --- /dev/null +++ b/superset-frontend/playwright/helpers/api/embedded.ts @@ -0,0 +1,113 @@ +/** + * 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 } 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 { + const response = await apiPut( + page, + `${DASHBOARD_ENDPOINTS.DASHBOARD}${dashboardIdOrSlug}/embedded`, + { allowed_domains: allowedDomains }, + ); + const body = await response.json(); + return body.result as EmbeddedConfig; +} + +/** + * Get a guest token for an embedded dashboard. + * Uses the admin login flow (login → access_token → guest_token). + * @param page - Playwright page instance (used for request context) + * @param dashboardId - Dashboard id to grant access to + * @param options - Optional login credentials and RLS rules + * @returns Signed guest token string + */ +export async function getGuestToken( + page: Page, + dashboardId: number | string, + options?: { + username?: string; + password?: string; + rls?: Array<{ dataset: number; clause: string }>; + }, +): Promise { + const username = options?.username ?? 'admin'; + const password = options?.password ?? 'general'; + const rls = options?.rls ?? []; + + // Step 1: Login to get access token + const loginResponse = await apiPost( + page, + ENDPOINTS.SECURITY_LOGIN, + { + username, + password, + provider: 'db', + refresh: true, + }, + { allowMissingCsrf: true }, + ); + const loginBody = await loginResponse.json(); + const accessToken = loginBody.access_token; + + // Step 2: Fetch guest token using the access token. + // Uses raw page.request.post() (not apiPost) because the guest token endpoint + // requires a JWT Bearer token rather than session+CSRF auth. + 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}`, + }, + }); + const guestBody = await guestResponse.json(); + return guestBody.token; +} diff --git a/superset-frontend/playwright/pages/EmbeddedPage.ts b/superset-frontend/playwright/pages/EmbeddedPage.ts new file mode 100644 index 00000000000..438c0a7b4f0 --- /dev/null +++ b/superset-frontend/playwright/pages/EmbeddedPage.ts @@ -0,0 +1,140 @@ +/** + * 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 } from '@playwright/test'; +import { EMBEDDED } from '../utils/constants'; + +/** + * Page object for the embedded dashboard test app. + * + * The test app runs on a separate origin (localhost:9000) 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): Promise { + await this.page.exposeFunction('__fetchGuestToken', tokenFn); + } + + /** + * Navigate to the embedded test app with the given parameters. + */ + async goto(params: { + uuid: string; + supersetDomain: string; + hideTitle?: boolean; + hideTab?: boolean; + hideChartControls?: boolean; + debug?: boolean; + }): Promise { + 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(`${EMBEDDED.APP_URL}/?${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. + */ + async waitForIframe(options?: { timeout?: number }): Promise { + await this.page.locator(EmbeddedPage.SELECTORS.IFRAME).waitFor({ + state: 'attached', + 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 { + const frame = this.iframe; + await frame + .locator('.grid-container, [data-test="grid-container"]') + .first() + .waitFor({ + state: 'visible', + timeout: options?.timeout ?? EMBEDDED.DASHBOARD_RENDER, + }); + } + + /** + * Get the status text from the test app. + */ + async getStatus(): Promise { + return ( + (await this.page.locator(EmbeddedPage.SELECTORS.STATUS).textContent()) ?? + '' + ); + } + + /** + * Get the error text, if any. + */ + async getError(): Promise { + 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()) ?? ''; + } + + /** + * Check if the dashboard title is visible inside the iframe. + */ + async isTitleVisible(): Promise { + const frame = this.iframe; + return frame + .locator( + '[data-test="dashboard-header-container"] [data-test="editable-title-input"]', + ) + .isVisible(); + } +} diff --git a/superset-frontend/playwright/tests/embedded/embedded-dashboard.spec.ts b/superset-frontend/playwright/tests/embedded/embedded-dashboard.spec.ts new file mode 100644 index 00000000000..75a47c87156 --- /dev/null +++ b/superset-frontend/playwright/tests/embedded/embedded-dashboard.spec.ts @@ -0,0 +1,303 @@ +/** + * 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 { readFileSync, existsSync } from 'fs'; +import { join, extname } from 'path'; +import { apiEnableEmbedding, getGuestToken } from '../../helpers/api/embedded'; +import { getDashboardBySlug } from '../../helpers/api/dashboard'; +import { EmbeddedPage } from '../../pages/EmbeddedPage'; +import { EMBEDDED } from '../../utils/constants'; + +/** + * MIME types for the static file server + */ +const MIME_TYPES: Record = { + '.html': 'text/html', + '.js': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', +}; + +/** + * 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 HTML from embedded-app/ and the SDK bundle from superset-embedded-sdk/bundle/. + */ +function createEmbedAppServer(): Server { + return createServer((req: IncomingMessage, res: ServerResponse) => { + const urlPath = req.url?.split('?')[0] || '/'; + + // Serve SDK bundle at /sdk/index.js + 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; + } + + // Serve static files from embedded-app/ + const filePath = join( + EMBED_APP_DIR, + urlPath === '/' ? 'index.html' : urlPath, + ); + if (!existsSync(filePath)) { + res.writeHead(404); + res.end('Not found'); + return; + } + + const ext = extname(filePath); + const contentType = MIME_TYPES[ext] || 'application/octet-stream'; + res.writeHead(200, { 'Content-Type': contentType }); + res.end(readFileSync(filePath)); + }); +} + +/** + * 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 { + 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 on a fixed port and must not run in parallel. +test.describe('Embedded Dashboard E2E', () => { + test.describe.configure({ mode: 'serial' }); + + let server: Server; + 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 { + const embeddedPage = new EmbeddedPage(page); + await embeddedPage.exposeTokenFetcher(async () => + getGuestToken(page, dashboardId), + ); + await embeddedPage.goto({ + 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', + ); + + // Start the embedded test app server + server = createEmbedAppServer(); + await new Promise((resolve, reject) => { + server.on('error', reject); + server.listen(EMBEDDED.APP_PORT, () => resolve()); + }); + + // Use a fresh context with auth to set up test data via API + const context = await createAdminContext(browser); + const setupPage = await context.newPage(); + + try { + // Find a well-known example dashboard + 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; + } finally { + await context.close(); + } + }); + + test.afterAll(async () => { + if (server) { + await new Promise(resolve => server.close(() => resolve())); + } + }); + + test('dashboard renders in embedded iframe', async ({ page }) => { + const embeddedPage = await setupEmbeddedPage(page); + + // Verify the iframe src points to Superset's /embedded/ endpoint + const iframeSrc = await page + .locator('iframe[title="Embedded Dashboard"]') + .getAttribute('src'); + expect(iframeSrc).toContain(`/embedded/${embedUuid}`); + + // Verify no errors in the test app + const error = await embeddedPage.getError(); + expect(error).toBe(''); + + // Baseline: title should be visible when hideTitle is not set + const titleVisible = await embeddedPage.isTitleVisible(); + expect(titleVisible).toBe(true); + }); + + test('UI config hideTitle hides dashboard title', async ({ page }) => { + const embeddedPage = new EmbeddedPage(page); + await embeddedPage.exposeTokenFetcher(async () => + getGuestToken(page, dashboardId), + ); + await embeddedPage.goto({ + uuid: embedUuid, + supersetDomain: SUPERSET_DOMAIN, + hideTitle: true, + }); + await embeddedPage.waitForIframe(); + await embeddedPage.waitForDashboardContent(); + + // The iframe URL should include uiConfig parameter + const iframeSrc = await page + .locator('iframe[title="Embedded Dashboard"]') + .getAttribute('src'); + expect(iframeSrc).toContain('uiConfig='); + + // Verify the title is actually hidden inside the iframe + const titleVisible = await embeddedPage.isTitleVisible(); + expect(titleVisible).toBe(false); + }); + + test('charts render inside embedded iframe', async ({ page }) => { + const embeddedPage = await setupEmbeddedPage(page); + + // Verify chart containers are present and visible in the iframe + const charts = embeddedPage.iframe.locator( + '.chart-container, [data-test="chart-container"]', + ); + await expect(charts.first()).toBeVisible({ + timeout: EMBEDDED.DASHBOARD_RENDER, + }); + }); + + 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 localhost:9000 + const restrictedEmbed = await apiEnableEmbedding(setupPage, dashboardId, [ + 'https://allowed.example.com', + ]); + + const embeddedPage = new EmbeddedPage(page); + await embeddedPage.exposeTokenFetcher(async () => + getGuestToken(page, dashboardId), + ); + await embeddedPage.goto({ + uuid: restrictedEmbed.uuid, + supersetDomain: SUPERSET_DOMAIN, + }); + + // The iframe should load but get a 403 from Superset's referrer check + await embeddedPage.waitForIframe(); + + // The dashboard content should NOT render (403 blocks the embedded page) + const content = embeddedPage.iframe.locator( + '.grid-container, [data-test="grid-container"]', + ); + await expect(content).not.toBeVisible({ timeout: 5000 }); + } finally { + // Restore the open embedding config for other tests + 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); + }); + + await embeddedPage.goto({ + uuid: embedUuid, + supersetDomain: SUPERSET_DOMAIN, + }); + await embeddedPage.waitForIframe(); + await embeddedPage.waitForDashboardContent(); + + // The SDK should have called fetchGuestToken at least once + expect(tokenCallCount).toBeGreaterThanOrEqual(1); + + // Verify charts are actually rendering data (not just loading spinners) + const charts = embeddedPage.iframe.locator( + '.chart-container, [data-test="chart-container"]', + ); + const chartCount = await charts.count(); + expect(chartCount).toBeGreaterThan(0); + }); +}); diff --git a/superset-frontend/playwright/utils/constants.ts b/superset-frontend/playwright/utils/constants.ts index ab142b01208..df0386a2030 100644 --- a/superset-frontend/playwright/utils/constants.ts +++ b/superset-frontend/playwright/utils/constants.ts @@ -75,3 +75,18 @@ export const TIMEOUT = { */ SLOW_TEST: 60000, // 60s for tests that chain multiple slow operations } 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 = { + /** Port for the embedded test app static server */ + APP_PORT: 9000, + /** Full URL for the embedded test app */ + APP_URL: 'http://localhost:9000', + /** 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 +} as const;