diff --git a/REFERENCE.md b/REFERENCE.md index 65e36a30..1bb0e762 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -708,6 +708,12 @@ ExplorationActionControl.indicator?: 'chevron' | 'ellipsis' — Chart labels use ExplorationActionControl.ref?: Ref — Shares this menu trigger with an item's context and keyboard actions. ExplorationActionControl.className?: string ExplorationActionControlProps (type, @altertable/data-app-react/ui): = props listed as ExplorationActionControl.* +ExternalLink (component, @altertable/data-app-react): (props: ExternalLinkProps) — Open HTTPS links through the host when embedded, or a native new tab when standalone. +ExternalLink.href: string +ExternalLink.children: ReactNode +ExternalLink.className?: string +ExternalLink.title?: string +ExternalLinkProps (type, @altertable/data-app-react): = props listed as ExternalLink.* FilterActions (component, @altertable/data-app-react/ui): (props: FilterActionsProps) FilterActions.onClear?: (() => void) FilterActions.onApply: () => void [variant 1 of 2] diff --git a/dev/fixtures/bundle-host.tsx b/dev/fixtures/bundle-host.tsx index 32122ec3..5a7b4e96 100644 --- a/dev/fixtures/bundle-host.tsx +++ b/dev/fixtures/bundle-host.tsx @@ -2,6 +2,7 @@ import { type DataAppLogger, type DataAppStatus, createNavigationHandler, + createExternalLinkHandler, createSqlQueryHandler, } from '@altertable/data-app-embed'; import { DataAppBridge } from '@altertable/data-app-embed/react'; @@ -23,6 +24,7 @@ import { annotationSendRoute, annotationUpdateRoute, createMessageRouter, + openExternalLinkRoute, defineMessageRoute, sqlQueryRoute, DataSourceError, @@ -219,6 +221,8 @@ function Host() { ...(hostOptions.has('lazy') ? { loading: 'lazy' as const } : {}), }; const forward = createHttpTransport(); + const [openExternalLink] = useState(() => createExternalLinkHandler()); + const fileExportRoute = defineMessageRoute({ input(value: unknown): { filename: string; blob: Blob } { if (!value || typeof value !== 'object') @@ -260,6 +264,7 @@ function Host() { 'annotation:send': annotationSendRoute, 'annotation:update': annotationUpdateRoute, 'data:sql': sqlQueryRoute, + 'navigation:openExternal': openExternalLinkRoute, 'export:csv': fileExportRoute, 'export:zip': fileExportRoute, }, @@ -275,6 +280,7 @@ function Host() { if (annotationFailure) throw new Error('Fixture failure'); return annotationsHost.handlers['annotation:draft'](draft); }, + 'navigation:openExternal': openExternalLink, 'export:csv': downloadExport, 'export:zip': downloadExport, 'data:sql': createSqlQueryHandler(async () => ({ diff --git a/docs/app-authoring.md b/docs/app-authoring.md index f2801a21..8381e91c 100644 --- a/docs/app-authoring.md +++ b/docs/app-authoring.md @@ -52,6 +52,29 @@ Keep credentials, authorization, and enforced query limits in the host/backend. The starter supplies transport and mounting; change them only for an explicit integration task. +## Link to external pages + +Use `` for HTTPS destinations in widget content: + +```tsx +import { ExternalLink } from '@altertable/data-app-react'; + +function ReportLink() { + return ( + + View full report + + ); +} +``` + +For programmatic navigation, import `navigateNewTab`, `navigateNewWindow`, or +`navigateNewWindowOrTab` from `@altertable/data-app/client`. For example, +`navigateNewWindowOrTab(url, event)` accepts a DOM or React event: Shift requests +a window, Ctrl/Cmd requests a tab, and an unmodified event does nothing. + +Embedded hosts must enable the [external link route](embed.md#external-links). + ## Verify the app Run the authoring workflow's validation, then inspect the app: diff --git a/docs/embed.md b/docs/embed.md index ca76d773..939d968b 100644 --- a/docs/embed.md +++ b/docs/embed.md @@ -86,6 +86,44 @@ Host Back/Forward events publish opaque host state through the bridge. The app's optional navigation adapter interprets search/hash and synchronizes controls; opaque apps keep that state in memory rather than modifying their document URL. +## External links + +Register the external link route to let apps request HTTPS navigation without +relaxing the iframe sandbox: + +```ts +import { + createMessageRouter, + openExternalLinkRoute, +} from '@altertable/data-app/contract'; +import { createExternalLinkHandler } from '@altertable/data-app-embed'; + +const router = createMessageRouter( + { 'navigation:openExternal': openExternalLinkRoute }, + { + 'navigation:openExternal': createExternalLinkHandler({ + allowedOrigins: ['https://example.com'], + }), + } +); +// Pass router.dispatch as the bridge's onMessage handler. +``` + +To use an existing modifier-based host navigation helper, register it directly: + +```ts +const openExternalLink = createExternalLinkHandler({ + navigate: navigateNewWindowOrTab, +}); +// Register openExternalLink for navigation:openExternal in the host router. +``` + +Here `navigateNewWindowOrTab` is the host's `(url, event)` navigation helper. +Use the frontend helper, not the app-side bridge client with the same name. + +Choose permitted origins according to the host's data policy: URLs can carry app +data, and the initial origin check does not constrain redirects at the destination. + ## Status and diagnostics `onStatusChange` receives `connecting`, `connected`, `ready`, `failed`, or diff --git a/packages/data-app-bun/src/preview/host.tsx b/packages/data-app-bun/src/preview/host.tsx index 3fa9cd43..0852c79d 100644 --- a/packages/data-app-bun/src/preview/host.tsx +++ b/packages/data-app-bun/src/preview/host.tsx @@ -1,6 +1,7 @@ import { createDataAppRepairSession, createNavigationHandler, + createExternalLinkHandler, createSqlQueryHandler, type DataAppStatus, } from '@altertable/data-app-embed'; @@ -11,6 +12,7 @@ import { import { DataSourceError, createMessageRouter, + openExternalLinkRoute, defineMessageRoute, navigationUpdateRoute, sqlQueryRoute, @@ -19,6 +21,8 @@ import { downloadBlob } from '@altertable/data-app/internal'; import { StrictMode, useEffect, useState } from 'react'; import { createRoot } from 'react-dom/client'; +const openExternalLink = createExternalLinkHandler(); + const fileExportRoute = defineMessageRoute({ input(value: unknown): { filename: string; blob: Blob } { const file = value as { filename?: unknown; blob?: unknown } | null; @@ -41,6 +45,7 @@ const router = createMessageRouter( { 'data:sql': sqlQueryRoute, 'navigation:update': navigationUpdateRoute, + 'navigation:openExternal': openExternalLinkRoute, 'export:csv': fileExportRoute, 'export:zip': fileExportRoute, }, @@ -63,6 +68,7 @@ const router = createMessageRouter( }, })), 'navigation:update': createNavigationHandler(), + 'navigation:openExternal': openExternalLink, 'export:csv': download, 'export:zip': download, } diff --git a/packages/data-app-embed/src/external-link.ts b/packages/data-app-embed/src/external-link.ts new file mode 100644 index 00000000..aed4660a --- /dev/null +++ b/packages/data-app-embed/src/external-link.ts @@ -0,0 +1,127 @@ +import { + openExternalLinkRoute, + resolveExternalLinkTarget, + MessageRoutingError, + type OpenExternalLink, + type ExternalLinkResult, + type MessageContext, +} from '@altertable/data-app/contract'; + +/** Show a host-owned confirmation before external navigation. No iframe permissions are added. */ +export function createExternalLinkHandler({ + window: host = window, + allowedOrigins, + navigate, +}: { + window?: Window; + /** Exact HTTPS origins. Omit to allow any HTTPS destination after confirmation. */ + allowedOrigins?: readonly string[]; + /** Called synchronously on the host confirmation click instead of native navigation. + * Receives an undispatched, synthetic KeyboardEvent with the original modifier keys. + * Explicit tab/window requests map to Ctrl/Shift so modifier-based helpers work directly. + * The callback owns popup opening and opener/referrer protection; do not await before opening. + */ + navigate?: (url: string, event: KeyboardEvent) => void; +} = {}) { + const origins = + allowedOrigins && + new Set( + allowedOrigins.map(origin => { + const url = new URL(origin); + if (url.protocol !== 'https:' || url.origin !== origin) + throw new Error('Expected an exact HTTPS origin.'); + return origin; + }) + ); + let pending = false; + return async function openExternalLink( + input: OpenExternalLink, + { signal }: MessageContext + ): Promise { + signal.throwIfAborted(); + const request = openExternalLinkRoute.input(input); + const { url } = request; + if (origins && !origins.has(new URL(url).origin)) + throw new MessageRoutingError( + 'forbidden', + 'This external destination is not allowed.' + ); + const target = resolveExternalLinkTarget(request); + if (!target) return 'ignored'; + const keys = + request.target === 'event' + ? request.keys + : { + shiftKey: target === 'window', + ctrlKey: target === 'tab', + metaKey: false, + }; + if (pending) + throw new MessageRoutingError( + 'external_link_busy', + 'An external link is already awaiting confirmation.' + ); + pending = true; + const document = host.document; + const previousFocus = document.activeElement; + const dialog = document.createElement('dialog'); + dialog.setAttribute('aria-label', 'Open external link'); + const description = document.createElement('p'); + description.textContent = 'This app wants to open an external website:'; + const destination = document.createElement('p'); + destination.textContent = url; + destination.style.overflowWrap = 'anywhere'; + const link = document.createElement('a'); + link.href = url; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + link.textContent = `Open ${new URL(url).host}`; + const cancel = document.createElement('button'); + cancel.textContent = 'Cancel'; + cancel.autofocus = true; + dialog.append(description, destination, link, ' ', cancel); + document.body.append(dialog); + let cleanup: (() => void) | undefined; + try { + return await new Promise((resolve, reject) => { + function abort() { + reject(signal.reason); + } + signal.addEventListener('abort', abort, { once: true }); + dialog.addEventListener('close', () => resolve('cancelled')); + cancel.addEventListener('click', () => dialog.close()); + function open(event: MouseEvent) { + if (event.type === 'auxclick' && event.button !== 1) return; + event.preventDefault(); + try { + if (navigate) navigate(url, new KeyboardEvent('navigation', keys)); + else + host.open( + url, + '_blank', + target === 'window' + ? 'popup,noopener,noreferrer' + : 'noopener,noreferrer' + ); + } catch (error) { + reject(error); + return; + } + resolve('opened'); + } + link.addEventListener('click', open); + link.addEventListener('auxclick', open); + // Remove the abort listener when the dialog settles, including showModal failures. + cleanup = () => signal.removeEventListener('abort', abort); + dialog.showModal(); + }); + } finally { + cleanup?.(); + dialog.close(); + dialog.remove(); + pending = false; + if (previousFocus && 'focus' in previousFocus) + (previousFocus as HTMLElement).focus(); + } + }; +} diff --git a/packages/data-app-embed/src/index.ts b/packages/data-app-embed/src/index.ts index 6043bc28..19917088 100644 --- a/packages/data-app-embed/src/index.ts +++ b/packages/data-app-embed/src/index.ts @@ -23,3 +23,5 @@ export { type DataAppRepairOptions, type DataAppRepairStatus, } from './repair.ts'; + +export { createExternalLinkHandler } from './external-link.ts'; diff --git a/packages/data-app-react/src/authoring/index.ts b/packages/data-app-react/src/authoring/index.ts index 840e3759..a56880e1 100644 --- a/packages/data-app-react/src/authoring/index.ts +++ b/packages/data-app-react/src/authoring/index.ts @@ -106,3 +106,5 @@ export type { SectionHeadingProps } from '../ui/SectionHeading.tsx'; export { DateDisplay } from '../ui/DateDisplay.tsx'; export type { DateDisplayProps } from '../ui/DateDisplay.tsx'; export { useDateFormatting } from '../date-formatting.ts'; + +export { ExternalLink, type ExternalLinkProps } from '../ui/ExternalLink.tsx'; diff --git a/packages/data-app-react/src/index.ts b/packages/data-app-react/src/index.ts index ccc52092..dadfc794 100644 --- a/packages/data-app-react/src/index.ts +++ b/packages/data-app-react/src/index.ts @@ -108,3 +108,5 @@ export { injectDataAppAnnotationStyles, } from './host/index.ts'; export type { AnnotationBarHandle, AnnotationBarProps } from './host/index.ts'; + +export { ExternalLink, type ExternalLinkProps } from './authoring/index.ts'; diff --git a/packages/data-app-react/src/ui/ExternalLink.tsx b/packages/data-app-react/src/ui/ExternalLink.tsx new file mode 100644 index 00000000..592c61d0 --- /dev/null +++ b/packages/data-app-react/src/ui/ExternalLink.tsx @@ -0,0 +1,93 @@ +import { + getDataAppTransport, + navigateNewTab, + navigateNewWindowOrTab, +} from '@altertable/data-app/client'; +import { parseExternalUrl } from '@altertable/data-app/contract'; +import { + useEffect, + useRef, + useState, + type ReactNode, + type MouseEvent, +} from 'react'; + +export type ExternalLinkProps = { + href: string; + children: ReactNode; + className?: string; + title?: string; +}; + +/** Open HTTPS links through the host when embedded, or a native new tab when standalone. */ +export function ExternalLink({ + href, + children, + className, + title, +}: ExternalLinkProps) { + const [error, setError] = useState(false); + const [pending, setPending] = useState(false); + const request = useRef(undefined); + useEffect(() => () => request.current?.abort(), []); + let url: string | undefined; + try { + url = parseExternalUrl(href); + } catch { + /* Invalid destinations are not clickable. */ + } + function open(event: MouseEvent) { + const transport = getDataAppTransport(); + if (!url || pending) { + event.preventDefault(); + return; + } + if (!transport) return; + event.preventDefault(); + if (request.current) return; + const controller = new AbortController(); + request.current = controller; + setPending(true); + setError(false); + const options = { signal: controller.signal }; + const navigation = + event.shiftKey || event.metaKey || event.ctrlKey + ? navigateNewWindowOrTab(url, event, options) + : navigateNewTab(url, options); + void navigation + .catch(() => { + if (!controller.signal.aborted) setError(true); + }) + .finally(() => { + if (request.current !== controller) return; + request.current = undefined; + setPending(false); + }); + } + return ( + <> + { + if (event.button === 1) open(event); + }} + > + {children} + + {(!url || error) && ( + + {!url + ? 'Invalid external link.' + : 'Could not open this link. The host may not support external links.'} + + )} + + ); +} diff --git a/packages/data-app/src/client/external-link.ts b/packages/data-app/src/client/external-link.ts new file mode 100644 index 00000000..a6bfd51d --- /dev/null +++ b/packages/data-app/src/client/external-link.ts @@ -0,0 +1,77 @@ +import { + openExternalLinkRoute, + resolveExternalLinkTarget, + type OpenExternalLink, + type ExternalLinkModifiers, + type ExternalLinkResult, +} from '../core/external-link.ts'; +import { MessageRoutingError } from '../core/messages.ts'; +import { getDataAppTransport } from './iframe.ts'; + +type NavigationOptions = { signal?: AbortSignal }; + +async function navigate( + input: OpenExternalLink, + { signal }: NavigationOptions = {} +): Promise { + signal?.throwIfAborted(); + const payload = openExternalLinkRoute.input(input); + const transport = getDataAppTransport(); + if (transport) { + const result = await transport.request( + { route: 'navigation:openExternal', payload }, + signal + ); + signal?.throwIfAborted(); + return openExternalLinkRoute.output(result, payload); + } + if (window.parent !== window) + throw new MessageRoutingError( + 'bridge_unavailable', + 'The host navigation bridge is unavailable.' + ); + const target = resolveExternalLinkTarget(payload); + if (!target) return 'ignored'; + window.open( + payload.url, + '_blank', + target === 'window' ? 'popup,noopener,noreferrer' : 'noopener,noreferrer' + ); + return 'opened'; +} + +/** Request a new tab through the host, or open directly when standalone. Browser settings determine placement. */ +export function navigateNewTab( + url: string, + options?: NavigationOptions +): Promise { + return navigate({ url, target: 'tab' }, options); +} + +/** Request a separate window through the host, or a popup when standalone. Browser settings determine placement. */ +export function navigateNewWindow( + url: string, + options?: NavigationOptions +): Promise { + return navigate({ url, target: 'window' }, options); +} + +/** Send only modifier keys, never the DOM/React event. Shift requests a window, Ctrl/Cmd a tab; otherwise no navigation. */ +export function navigateNewWindowOrTab( + url: string, + event: ExternalLinkModifiers, + options?: NavigationOptions +): Promise { + return navigate( + { + url, + target: 'event', + keys: { + shiftKey: event.shiftKey, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + }, + }, + options + ); +} diff --git a/packages/data-app/src/client/index.ts b/packages/data-app/src/client/index.ts index 3701835b..92e3d7fb 100644 --- a/packages/data-app/src/client/index.ts +++ b/packages/data-app/src/client/index.ts @@ -33,3 +33,9 @@ export { type AppFailure, type AppDiagnosticReport, } from '../core/diagnostics.ts'; + +export { + navigateNewTab, + navigateNewWindow, + navigateNewWindowOrTab, +} from './external-link.ts'; diff --git a/packages/data-app/src/core/contract.ts b/packages/data-app/src/core/contract.ts index eceba9c7..808d571c 100644 --- a/packages/data-app/src/core/contract.ts +++ b/packages/data-app/src/core/contract.ts @@ -126,3 +126,13 @@ export type { DataAppAnnotationDraft, DataAppAnnotationPresentation, } from './annotations.ts'; + +export { + parseExternalUrl, + resolveExternalLinkTarget, + type ExternalLinkTarget, + type ExternalLinkModifiers, + openExternalLinkRoute, + type OpenExternalLink, + type ExternalLinkResult, +} from './external-link.ts'; diff --git a/packages/data-app/src/core/external-link.ts b/packages/data-app/src/core/external-link.ts new file mode 100644 index 00000000..8aad2f70 --- /dev/null +++ b/packages/data-app/src/core/external-link.ts @@ -0,0 +1,84 @@ +import { defineMessageRoute } from './messages.ts'; + +/** Absolute HTTPS destinations only; URLs must not contain embedded credentials. */ +export function parseExternalUrl(value: unknown): string { + if ( + typeof value !== 'string' || + value.length > 8192 || + /[\s\\\p{Cc}\p{Cf}]/u.test(value) + ) + throw new Error('Expected an absolute HTTPS URL.'); + const url = new URL(value); + if ( + !/^https:\/\//i.test(value) || + url.protocol !== 'https:' || + url.username || + url.password + ) + throw new Error('Expected an HTTPS URL without credentials.'); + return url.href; +} + +export type ExternalLinkTarget = 'tab' | 'window'; +export type ExternalLinkModifiers = { + shiftKey: boolean; + metaKey: boolean; + ctrlKey: boolean; +}; +export type OpenExternalLink = { url: string } & ( + | { target?: ExternalLinkTarget } + | { target: 'event'; keys: ExternalLinkModifiers } +); +export type ExternalLinkResult = 'opened' | 'cancelled' | 'ignored'; + +/** Shift takes precedence over Ctrl/Cmd; an unmodified event requests no navigation. */ +export function resolveExternalLinkTarget( + input: OpenExternalLink +): ExternalLinkTarget | null { + if (input.target !== 'event') return input.target ?? 'tab'; + return input.keys.shiftKey + ? 'window' + : input.keys.metaKey || input.keys.ctrlKey + ? 'tab' + : null; +} + +/** Opt-in host route. Validation does not replace the host's destination policy. */ +export const openExternalLinkRoute = /* @__PURE__ */ defineMessageRoute({ + input(value: unknown): OpenExternalLink { + if (!value || typeof value !== 'object') + throw new Error('Invalid external link.'); + const input = value as Record; + const url = parseExternalUrl(input.url); + if (input.target === undefined) return { url }; + if (input.target === 'tab' || input.target === 'window') + return { url, target: input.target }; + if ( + input.target === 'event' && + input.keys && + typeof input.keys === 'object' + ) { + const keys = input.keys as Record; + if ( + typeof keys.shiftKey === 'boolean' && + typeof keys.metaKey === 'boolean' && + typeof keys.ctrlKey === 'boolean' + ) + return { + url, + target: 'event', + keys: { + shiftKey: keys.shiftKey, + metaKey: keys.metaKey, + ctrlKey: keys.ctrlKey, + }, + }; + } + throw new Error('Invalid external navigation target.'); + }, + output(value: unknown): ExternalLinkResult { + if (value !== 'opened' && value !== 'cancelled' && value !== 'ignored') + throw new Error('Invalid external link response.'); + return value; + }, +}); diff --git a/tests/public-api/apps-external-links.test.ts b/tests/public-api/apps-external-links.test.ts new file mode 100644 index 00000000..d1a968c0 --- /dev/null +++ b/tests/public-api/apps-external-links.test.ts @@ -0,0 +1,281 @@ +import { expect } from 'vitest'; + +import { test } from '@/tests/public-api/browser'; + +test('sandboxed widget opens an approved external link only after host confirmation', async ({ + page, +}) => { + await page.context().route('https://example.com/**', route => + route.fulfill({ + contentType: 'text/html', + body: '

External report

', + }) + ); + await page.goto('/bundle-host'); + const app = page.frameLocator('iframe'); + await app.getByRole('link', { name: 'View full report' }).click(); + const dialog = page.getByRole('dialog', { name: 'Open external link' }); + await expect.poll(() => dialog.isVisible()).toBe(true); + expect(await dialog.textContent()).toContain( + 'https://example.com/report?source=widget' + ); + expect(page.context().pages()).toHaveLength(1); + expect(await page.locator('iframe').getAttribute('sandbox')).toBe( + 'allow-scripts' + ); + await dialog.getByRole('button', { name: 'Cancel' }).press('Escape'); + await expect.poll(() => dialog.count()).toBe(0); + await app.getByRole('link', { name: 'View full report' }).press('Enter'); + const opened = page.context().waitForEvent('page'); + await dialog.getByRole('link', { name: 'Open example.com' }).click(); + const external = await opened; + await external.waitForLoadState(); + expect(external.url()).toBe('https://example.com/report?source=widget'); + expect(await external.evaluate(() => window.opener)).toBeNull(); + expect(await external.evaluate(() => document.referrer)).toBe(''); + await expect.poll(() => dialog.count()).toBe(0); +}); + +test('denied and unsafe widget links do not navigate', async ({ page }) => { + await page.goto('/bundle-host'); + const app = page.frameLocator('iframe'); + await app.getByRole('link', { name: 'Denied report' }).click(); + await expect + .poll(() => + app.getByRole('alert').filter({ hasText: 'Could not open' }).isVisible() + ) + .toBe(true); + expect(await page.getByRole('dialog').count()).toBe(0); + expect( + await app.getByText('Unsafe report', { exact: true }).getAttribute('href') + ).toBeNull(); + expect(page.context().pages()).toHaveLength(1); +}); + +test('iframe reload dismisses a pending host confirmation and permits another request', async ({ + page, +}) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto('/bundle-host'); + await page.getByRole('button', { name: 'Change theme' }).click(); + const app = page.frameLocator('iframe'); + const link = app.getByRole('link', { name: 'View full report' }); + await link.click({ button: 'middle' }); + const dialog = page.getByRole('dialog', { name: 'Open external link' }); + await expect.poll(() => dialog.isVisible()).toBe(true); + await page.locator('iframe').evaluate(frame => { + frame.setAttribute('src', frame.getAttribute('src')!); + }); + await expect.poll(() => dialog.count()).toBe(0); + await link.click(); + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect.poll(() => dialog.count()).toBe(0); + expect(page.context().pages()).toHaveLength(1); +}); + +test('an Activity-hidden link cancels confirmation and works again when restored', async ({ + page, +}) => { + await page.goto('/bundle-host'); + const app = page.frameLocator('iframe'); + const link = app.getByRole('link', { name: 'View full report' }); + const dialog = page.getByRole('dialog', { name: 'Open external link' }); + await link.click(); + await expect.poll(() => dialog.isVisible()).toBe(true); + // Simulate a background visibility change while the host modal makes the app inert. + await app + .getByRole('button', { name: 'Hide report link' }) + .evaluate(button => (button as HTMLButtonElement).click()); + await expect.poll(() => dialog.count()).toBe(0); + await app.getByRole('button', { name: 'Show report link' }).click(); + await expect.poll(() => link.getAttribute('aria-disabled')).toBe('false'); + expect(await link.getAttribute('aria-busy')).toBeNull(); + await link.click(); + await expect.poll(() => dialog.isVisible()).toBe(true); + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect.poll(() => link.getAttribute('aria-disabled')).toBe('false'); + expect(page.context().pages()).toHaveLength(1); +}); + +test('host navigation helper uses the app request and retains host user activation', async ({ + page, +}) => { + await page + .context() + .route('https://example.com/**', route => + route.fulfill({ contentType: 'text/html', body: '

Report

' }) + ); + await page.goto('/bundle-host?custom-navigation'); + const app = page.frameLocator('iframe'); + const dialog = page.getByRole('dialog', { name: 'Open external link' }); + const navigation = page.getByLabel('External navigation'); + await app.getByRole('link', { name: 'Denied report' }).click(); + await expect + .poll(() => + app.getByRole('alert').filter({ hasText: 'Could not open' }).count() + ) + .toBe(1); + expect(await navigation.textContent()).toBe(''); + await app.getByRole('link', { name: 'View full report' }).click(); + await dialog.getByRole('button', { name: 'Cancel' }).click(); + expect(await navigation.textContent()).toBe(''); + await app.getByRole('link', { name: 'View full report' }).click(); + const opened = page.context().waitForEvent('page'); + await dialog.getByRole('link').click({ modifiers: ['Shift'] }); + const external = await opened; + await external.waitForLoadState(); + expect(JSON.parse((await navigation.textContent())!)).toEqual({ + url: 'https://example.com/report?source=widget', + target: 'tab', + keys: { shiftKey: false, metaKey: false, ctrlKey: true }, + keyboardEvent: true, + activated: true, + }); + expect(await external.evaluate(() => window.opener)).toBeNull(); + expect(await external.evaluate(() => document.referrer)).toBe(''); + expect(page.context().pages()).toHaveLength(2); + await expect.poll(() => dialog.count()).toBe(0); +}); + +test('a failing host navigation helper closes confirmation and permits retry', async ({ + page, +}) => { + await page.goto('/bundle-host?custom-navigation&navigation-error'); + const app = page.frameLocator('iframe'); + const link = app.getByRole('link', { name: 'View full report' }); + const dialog = page.getByRole('dialog', { name: 'Open external link' }); + await link.click(); + await dialog.getByRole('link').click({ button: 'middle' }); + await expect.poll(() => dialog.count()).toBe(0); + await expect + .poll(() => + app.getByRole('alert').filter({ hasText: 'Could not open' }).count() + ) + .toBe(1); + await expect.poll(() => link.getAttribute('aria-disabled')).toBe('false'); + expect(page.context().pages()).toHaveLength(1); + await link.click(); + await dialog.getByRole('button', { name: 'Cancel' }).click(); + await expect.poll(() => dialog.count()).toBe(0); +}); + +for (const { name, button, modifiers, target } of [ + { + name: 'explicit tab', + button: 'Request new tab', + modifiers: [], + target: 'tab', + }, + { + name: 'explicit window', + button: 'Request new window', + modifiers: [], + target: 'window', + }, + { + name: 'Shift event', + button: 'Request from event', + modifiers: ['Shift'], + target: 'window', + }, + { + name: 'Ctrl event', + button: 'Request from event', + modifiers: ['Control'], + target: 'tab', + }, + { + name: 'Cmd event', + button: 'Request from event', + modifiers: ['Meta'], + target: 'tab', + }, + { + name: 'Shift precedence', + button: 'Request from event', + modifiers: ['Shift', 'Control'], + target: 'window', + }, +] as const) { + test(`bridge navigation supports ${name}`, async ({ page }) => { + await page + .context() + .route('https://example.com/**', route => + route.fulfill({ contentType: 'text/html', body: 'Report' }) + ); + await page.goto('/bundle-host?custom-navigation'); + const app = page.frameLocator('iframe'); + await app + .getByRole('button', { name: button, exact: true }) + .press([...modifiers, 'Enter'].join('+')); + const dialog = page.getByRole('dialog', { name: 'Open external link' }); + await expect.poll(() => dialog.isVisible()).toBe(true); + const opened = page.context().waitForEvent('page'); + // Confirmation modifiers must not replace the original frame's intent. + await dialog + .getByRole('link') + .click({ modifiers: target === 'tab' ? ['Shift'] : [] }); + const external = await opened; + await external.waitForLoadState(); + expect( + JSON.parse((await page.getByLabel('External navigation').textContent())!) + ).toEqual({ + url: 'https://example.com/report', + target, + activated: true, + keyboardEvent: true, + keys: + button === 'Request from event' + ? { + shiftKey: (modifiers as readonly string[]).includes('Shift'), + metaKey: (modifiers as readonly string[]).includes('Meta'), + ctrlKey: (modifiers as readonly string[]).includes('Control'), + } + : { + shiftKey: target === 'window', + metaKey: false, + ctrlKey: target === 'tab', + }, + }); + await expect + .poll(() => app.getByLabel('External navigation result').textContent()) + .toBe('opened'); + expect(page.context().pages()).toHaveLength(2); + }); +} + +test('unmodified event navigation is ignored without confirmation', async ({ + page, +}) => { + await page.goto('/bundle-host?custom-navigation'); + const app = page.frameLocator('iframe'); + await app.getByRole('button', { name: 'Request from event' }).click(); + await expect + .poll(() => app.getByLabel('External navigation result').textContent()) + .toBe('ignored'); + expect(await page.getByRole('dialog').count()).toBe(0); + expect(await page.getByLabel('External navigation').textContent()).toBe(''); + expect(page.context().pages()).toHaveLength(1); +}); + +test('ExternalLink forwards original Shift navigation to the host', async ({ + page, +}) => { + await page + .context() + .route('https://example.com/**', route => + route.fulfill({ contentType: 'text/html', body: 'Report' }) + ); + await page.goto('/bundle-host?custom-navigation'); + const app = page.frameLocator('iframe'); + await app + .getByRole('link', { name: 'View full report' }) + .click({ modifiers: ['Shift'] }); + const opened = page.context().waitForEvent('page'); + await page.getByRole('dialog').getByRole('link').click(); + await (await opened).waitForLoadState(); + expect( + JSON.parse((await page.getByLabel('External navigation').textContent())!) + .target + ).toBe('window'); +}); diff --git a/tests/public-api/external-links.test.ts b/tests/public-api/external-links.test.ts new file mode 100644 index 00000000..cacb4b8e --- /dev/null +++ b/tests/public-api/external-links.test.ts @@ -0,0 +1,145 @@ +import { createExternalLinkHandler } from '@altertable/data-app-embed'; +import { + navigateNewTab, + navigateNewWindow, + navigateNewWindowOrTab, +} from '@altertable/data-app/client'; +import { + createMessageRouter, + openExternalLinkRoute, + resolveExternalLinkTarget, +} from '@altertable/data-app/contract'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +afterEach(() => vi.unstubAllGlobals()); + +describe('external link policy', () => { + test.each([ + 'javascript:alert(1)', + 'data:text/html,hi', + 'http://example.com', + '//example.com', + '/report', + 'https:example.com', + 'https://user:secret@example.com', + 'https://example.com\\@evil.test', + 'https://example.com/\nreport', + '', + 'https://example.com/' + 'a'.repeat(8192), + ])('rejects unsafe destination %s', url => { + expect(() => openExternalLinkRoute.input({ url })).toThrow(); + }); + test('normalizes HTTPS destinations and rejects invalid responses', () => { + expect( + openExternalLinkRoute.input({ url: 'https://EXAMPLE.com:443/report' }) + ).toEqual({ url: 'https://example.com/report' }); + expect(() => + openExternalLinkRoute.output('success', { url: '' }) + ).toThrow(); + }); + test('host rejects destinations outside its exact origin policy before touching the DOM', async () => { + const router = createMessageRouter( + { 'navigation:openExternal': openExternalLinkRoute }, + { + 'navigation:openExternal': createExternalLinkHandler({ + window: {} as Window, + allowedOrigins: ['https://example.com'], + }), + } + ); + for (const url of [ + 'https://example.com.evil.test/', + 'https://example.com:444/', + 'https://evil.test/', + ]) + await expect( + router.dispatch( + { route: 'navigation:openExternal', payload: { url } }, + { signal: new AbortController().signal } + ) + ).rejects.toMatchObject({ code: 'forbidden' }); + }); +}); + +test.each([ + { target: 'other' }, + { target: null }, + { target: 'event' }, + { + target: 'event', + keys: { shiftKey: 'true', ctrlKey: false, metaKey: false }, + }, + { target: 'event', keys: { shiftKey: false } }, +])('rejects malformed navigation intent %j', intent => { + expect(() => + openExternalLinkRoute.input({ url: 'https://example.com', ...intent }) + ).toThrow(); +}); + +test('serializes only original modifier keys and resolves Shift first', () => { + const request = openExternalLinkRoute.input({ + url: 'https://example.com', + target: 'event', + keys: { + shiftKey: true, + ctrlKey: true, + metaKey: true, + target: { nodeName: 'A' }, + }, + }); + expect(request).toEqual({ + url: 'https://example.com/', + target: 'event', + keys: { shiftKey: true, ctrlKey: true, metaKey: true }, + }); + expect(resolveExternalLinkTarget(request)).toBe('window'); + expect(resolveExternalLinkTarget({ url: 'https://example.com' })).toBe('tab'); +}); + +test('standalone helpers open synchronously and request the appropriate browser surface', async () => { + const host = { + open: vi.fn(), + get parent(): unknown { + return this; + }, + }; + vi.stubGlobal('window', host); + const tab = navigateNewTab('https://example.com'); + expect(host.open).toHaveBeenLastCalledWith( + 'https://example.com/', + '_blank', + 'noopener,noreferrer' + ); + await expect(tab).resolves.toBe('opened'); + const popup = navigateNewWindow('https://example.com'); + expect(host.open).toHaveBeenLastCalledWith( + 'https://example.com/', + '_blank', + 'popup,noopener,noreferrer' + ); + await expect(popup).resolves.toBe('opened'); + await expect( + navigateNewWindowOrTab('https://example.com', { + shiftKey: false, + ctrlKey: false, + metaKey: false, + }) + ).resolves.toBe('ignored'); + expect(host.open).toHaveBeenCalledTimes(2); + await expect(navigateNewTab('javascript:alert(1)')).rejects.toThrow(); + const controller = new AbortController(); + controller.abort(); + await expect( + navigateNewWindow('https://example.com', { signal: controller.signal }) + ).rejects.toThrow(); + expect(host.open).toHaveBeenCalledTimes(2); +}); + +test('helpers do not bypass an unavailable iframe bridge', async () => { + const open = vi.fn(); + vi.stubGlobal('window', { parent: {}, open }); + await expect(navigateNewTab('https://example.com')).rejects.toMatchObject({ + code: 'bridge_unavailable', + }); + expect(open).not.toHaveBeenCalled(); +}); diff --git a/tests/public-api/fixtures/bundle-app.tsx b/tests/public-api/fixtures/bundle-app.tsx index 3fbf2fe2..11073299 100644 --- a/tests/public-api/fixtures/bundle-app.tsx +++ b/tests/public-api/fixtures/bundle-app.tsx @@ -1,9 +1,12 @@ import { defineDataApp } from '@altertable/data-app'; -import { injectDataAppStyles } from '@altertable/data-app-react'; +import { ExternalLink, injectDataAppStyles } from '@altertable/data-app-react'; import { mountDataApp, searchVariable } from '@altertable/data-app-react'; import { DataApp, useAppVariables } from '@altertable/data-app-react/ui'; import { createMessageClient, + navigateNewTab, + navigateNewWindow, + navigateNewWindowOrTab, createDataClient, getDataAppTransport, getDataAppNavigation, @@ -13,7 +16,7 @@ import { connectionCheck, type ConnectionOperation, } from '@altertable/data-app/contract'; -import { useState } from 'react'; +import { Activity, useState } from 'react'; import { bridgeRoutes } from '@/tests/public-api/fixtures/bridge-routes'; const dataApp = defineDataApp({ @@ -40,9 +43,11 @@ const variables = { function App() { const [crashed, setCrashed] = useState(false); + const [reportVisible, setReportVisible] = useState(true); const state = useAppVariables(variables); const search = state.values.period ? `period=${state.values.period}` : ''; const [result, setResult] = useState(''); + const [navigationResult, setNavigationResult] = useState(''); const [content, setContent] = useState< 'short' | 'long' | 'viewport' | 'scaled' | 'oscillating' >('short'); @@ -69,6 +74,59 @@ function App() { toolbarActions={} footerActions={} > + + {navigationResult} + + + + + + + + View full report + + + + Denied report + + Unsafe report