This is an automated email from the ASF dual-hosted git repository.
bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new a93e9a2e5fb Sync the browser URL with navigation inside iframe views
(#72196)
a93e9a2e5fb is described below
commit a93e9a2e5fb1be490331a9d47ffdff59c6f2c991
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Tue Sep 8 15:30:35 2026 +0200
Sync the browser URL with navigation inside iframe views (#72196)
The legacy plugin views and the auth-manager security views (Users, Roles,
…)
render in an iframe, but the browser address bar stayed on the outer route
while users navigated inside the frame. That broke deep linking: an inner
page
such as editing a specific user could not be bookmarked, shared, or
reloaded,
and Back/Forward did nothing. Mirror the framed same-origin location to the
address bar and reconstruct it on load so those pages become linkable again.
---
.../src/airflow/ui/src/hooks/useIframeUrlSync.ts | 170 +++++++++++++++++++++
.../src/airflow/ui/src/pages/ExternalView.tsx | 1 +
.../src/airflow/ui/src/pages/Iframe.test.tsx | 99 ++++++++++++
airflow-core/src/airflow/ui/src/pages/Iframe.tsx | 21 ++-
.../src/airflow/ui/src/pages/Security.test.tsx | 93 +++++++++++
airflow-core/src/airflow/ui/src/pages/Security.tsx | 74 ++++-----
airflow-core/src/airflow/ui/src/router.tsx | 2 +-
.../ui/tests/e2e/specs/iframe-url-sync.spec.ts | 86 +++++++++++
8 files changed, 506 insertions(+), 40 deletions(-)
diff --git a/airflow-core/src/airflow/ui/src/hooks/useIframeUrlSync.ts
b/airflow-core/src/airflow/ui/src/hooks/useIframeUrlSync.ts
new file mode 100644
index 00000000000..fb6934c9395
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/hooks/useIframeUrlSync.ts
@@ -0,0 +1,170 @@
+/*!
+ * 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 { type RefObject, useEffect, useRef } from "react";
+
+import { useLocation, useNavigate, useParams } from "react-router-dom";
+
+// Resolve a URL-derived candidate and hand back only a same-origin,
root-relative path (else null).
+// The inner path comes from the address bar, so a crafted value must never
send the iframe
+// off-origin: resolving with the URL parser normalises every escaping trick
(protocol-relative
+// `//host`, backslashes, scheme, encoded/whitespace variants) and the origin
check rejects anything
+// that lands elsewhere; the leading-slash guard then guarantees a plain
root-relative path.
+const sameOriginPath = (candidate: string): string | null => {
+ try {
+ const resolved = new URL(candidate, globalThis.location.origin);
+
+ if (resolved.origin !== globalThis.location.origin) {
+ return null;
+ }
+
+ const path = `${resolved.pathname}${resolved.search}${resolved.hash}`;
+
+ return path.startsWith("/") && !path.startsWith("//") ? path : null;
+ } catch {
+ return null;
+ }
+};
+
+type UseIframeUrlSyncOptions = {
+ readonly basePath: string;
+ readonly enabled: boolean;
+ readonly entrySrc: string;
+ readonly iframeRef: RefObject<HTMLIFrameElement | null>;
+ // Optional bound on where the framed page may go. When it navigates to a
path this rejects, the
+ // user is sent home instead of the address bar being updated — so the main
app (and its own nav)
+ // is never rendered inside the iframe.
+ readonly isAllowedPath?: (pathname: string) => boolean;
+};
+
+/**
+ * Two-way sync between a same-origin iframe and the address bar so framed
pages are deep-linkable:
+ * the URL carries the inner path under `basePath`, navigation inside the
iframe updates it (via
+ * replace, leaving Back/Forward to the iframe's own history), and a
deep-linked URL loads that page.
+ */
+export const useIframeUrlSync = ({
+ basePath,
+ enabled,
+ entrySrc,
+ iframeRef,
+ isAllowedPath,
+}: UseIframeUrlSyncOptions): { initialSrc: string } => {
+ const splat = useParams()["*"] ?? "";
+ const { hash, search } = useLocation();
+ const navigate = useNavigate();
+
+ // Held in a ref so the load listener keeps a stable identity while calling
the latest predicate.
+ const isAllowedRef = useRef(isAllowedPath);
+
+ isAllowedRef.current = isAllowedPath;
+
+ // Last inner path reconciled with the address bar; keeps the two directions
from looping.
+ const syncedPath = useRef<string | null>(null);
+ // Frozen once so address-bar updates never reset the iframe src.
+ const initialSrc = useRef<string | null>(null);
+ // Set once we send the iframe out of the allowed area, so blanking it (a
further load) is a no-op.
+ const redirecting = useRef(false);
+
+ if (initialSrc.current === null && (splat !== "" || entrySrc !== "")) {
+ const deepLink = splat === "" ? null :
sameOriginPath(`/${splat}${search}${hash}`);
+
+ initialSrc.current = deepLink ?? entrySrc;
+ syncedPath.current = deepLink;
+ }
+
+ useEffect(() => {
+ const element = iframeRef.current;
+
+ if (!enabled || element === null) {
+ return undefined;
+ }
+
+ const handleLoad = () => {
+ const frameWindow = element.contentWindow;
+
+ if (frameWindow === null) {
+ return;
+ }
+
+ let href;
+ let pathname;
+ let frameSearch;
+ let frameHash;
+
+ try {
+ ({ hash: frameHash, href, pathname, search: frameSearch } =
frameWindow.location);
+ } catch {
+ return; // Cross-origin content is unreadable.
+ }
+
+ if (href === "about:blank") {
+ return;
+ }
+
+ const allowed = isAllowedRef.current;
+
+ if (allowed !== undefined && !allowed(pathname)) {
+ if (!redirecting.current) {
+ redirecting.current = true;
+ element.src = "about:blank";
+ void navigate("/");
+ }
+
+ return;
+ }
+
+ const key = `${pathname}${frameSearch}${frameHash}`;
+
+ if (key === syncedPath.current) {
+ return;
+ }
+ syncedPath.current = key;
+ void navigate(
+ { hash: frameHash, pathname: `${basePath}${pathname}`, search:
frameSearch },
+ { replace: true },
+ );
+ };
+
+ element.addEventListener("load", handleLoad);
+ handleLoad(); // A load can fire before this listener attaches; reconcile
the current state now.
+
+ return () => element.removeEventListener("load", handleLoad);
+ }, [enabled, basePath, navigate, iframeRef]);
+
+ useEffect(() => {
+ if (!enabled || splat === "") {
+ return;
+ }
+
+ const desired = sameOriginPath(`/${splat}${search}${hash}`);
+ const frameWindow = iframeRef.current?.contentWindow;
+
+ if (desired === null || desired === syncedPath.current || !frameWindow) {
+ return;
+ }
+ syncedPath.current = desired;
+
+ try {
+ frameWindow.location.replace(desired);
+ } catch {
+ // Cross-origin content cannot be driven from here.
+ }
+ }, [enabled, splat, search, hash, iframeRef]);
+
+ return { initialSrc: initialSrc.current ?? entrySrc };
+};
diff --git a/airflow-core/src/airflow/ui/src/pages/ExternalView.tsx
b/airflow-core/src/airflow/ui/src/pages/ExternalView.tsx
index 003b4b9a43a..ee16621289a 100644
--- a/airflow-core/src/airflow/ui/src/pages/ExternalView.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/ExternalView.tsx
@@ -74,6 +74,7 @@ export const ExternalView = () => {
They are not user provided plugins. */}
<Iframe
externalView={externalView}
+ key={page}
sandbox="allow-scripts allow-same-origin allow-forms allow-downloads"
/>
</Box>
diff --git a/airflow-core/src/airflow/ui/src/pages/Iframe.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Iframe.test.tsx
new file mode 100644
index 00000000000..431f3fc070e
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Iframe.test.tsx
@@ -0,0 +1,99 @@
+/*!
+ * 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 { fireEvent, render } from "@testing-library/react";
+import type * as ReactRouterDom from "react-router-dom";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { ExternalViewResponse } from "openapi/requests/types.gen";
+
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { Iframe } from "./Iframe";
+
+const navigate = vi.hoisted(() => vi.fn());
+
+vi.mock("react-router-dom", async (importOriginal) => ({
+ ...(await importOriginal<typeof ReactRouterDom>()),
+ useNavigate: () => navigate,
+}));
+
+vi.mock("openapi/queries", () => ({
+ useAssetServiceGetAsset: () => ({ data: undefined }),
+}));
+
+const navView = {
+ destination: "nav",
+ href: "/pluginsv2/",
+ name: "Legacy FAB views",
+ url_route: "legacy-fab-views",
+} as ExternalViewResponse;
+
+const renderAt = (entry: string) =>
+ render(
+ <MemoryRouter initialEntries={[entry]}>
+ <Routes>
+ <Route element={<Iframe externalView={navView} />}
path="plugin/:page/*" />
+ </Routes>
+ </MemoryRouter>,
+ { wrapper: BaseWrapper },
+ );
+
+beforeEach(() => {
+ navigate.mockClear();
+});
+
+describe("Iframe URL sync", () => {
+ it("loads the deep-linked inner path when the URL carries one", () => {
+ const { container } =
renderAt("/plugin/legacy-fab-views/pluginsv2/emptypluginview/");
+
+
expect(container.querySelector("iframe")?.getAttribute("src")).toBe("/pluginsv2/emptypluginview/");
+ });
+
+ it("falls back to the view's entry href when the URL has no inner path", ()
=> {
+ const { container } = renderAt("/plugin/legacy-fab-views");
+
+
expect(container.querySelector("iframe")?.getAttribute("src")).toBe("/pluginsv2/");
+ });
+
+ it("ignores a cross-origin deep link and falls back to the entry href", ()
=> {
+ // A protocol-relative segment crafted into the URL must not point the
iframe off-origin.
+ const { container } = renderAt("/plugin/legacy-fab-views//evil.com/x");
+
+
expect(container.querySelector("iframe")?.getAttribute("src")).toBe("/pluginsv2/");
+ });
+
+ it("mirrors the iframe's location into the address bar when it navigates
internally", () => {
+ const { container } = renderAt("/plugin/legacy-fab-views");
+ const iframe = container.querySelector("iframe");
+
+ // Stand in for the iframe having navigated to an inner page (jsdom does
not load real content).
+ Object.defineProperty(iframe, "contentWindow", {
+ configurable: true,
+ value: { location: { hash: "", pathname: "/pluginsv2/emptypluginview/",
search: "?q=1" } },
+ });
+ navigate.mockClear();
+ fireEvent.load(iframe as HTMLIFrameElement);
+
+ expect(navigate).toHaveBeenCalledWith(
+ { hash: "", pathname:
"/plugin/legacy-fab-views/pluginsv2/emptypluginview/", search: "?q=1" },
+ { replace: true },
+ );
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Iframe.tsx
b/airflow-core/src/airflow/ui/src/pages/Iframe.tsx
index f93b7372817..2952a79b411 100644
--- a/airflow-core/src/airflow/ui/src/pages/Iframe.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Iframe.tsx
@@ -16,11 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { useRef } from "react";
+
import { useParams } from "react-router-dom";
import { useAssetServiceGetAsset } from "openapi/queries";
import type { ExternalViewResponse } from "openapi/requests/types.gen";
+import { useIframeUrlSync } from "src/hooks/useIframeUrlSync";
+
export const Iframe = ({
externalView,
sandbox = "allow-forms",
@@ -28,7 +32,8 @@ export const Iframe = ({
readonly externalView: ExternalViewResponse;
readonly sandbox?: string;
}) => {
- const { assetId, dagId, mapIndex, runId, taskId } = useParams();
+ const { assetId, dagId, mapIndex, page, runId, taskId } = useParams();
+ const iframeRef = useRef<HTMLIFrameElement>(null);
// The asset URI is not part of the route, so resolve it from the asset
record. This is a
// cache hit because the asset details page has already fetched it.
@@ -38,6 +43,10 @@ export const Iframe = ({
{ enabled: Boolean(assetId) },
);
+ // Only standalone (nav) views are deep-linkable; context-scoped embeds
(dashboard/overview) keep
+ // the placeholder-substituted src and are not synced.
+ const isNavView = externalView.destination === undefined ||
externalView.destination === "nav";
+
// Build the href URL with context parameters if the view has a destination
let src = externalView.href;
@@ -68,10 +77,18 @@ export const Iframe = ({
src = new URL(src).toString();
}
+ const { initialSrc } = useIframeUrlSync({
+ basePath: `/plugin/${page ?? ""}`,
+ enabled: isNavView,
+ entrySrc: src,
+ iframeRef,
+ });
+
return (
<iframe
+ ref={iframeRef}
sandbox={sandbox}
- src={src}
+ src={isNavView ? initialSrc : src}
style={{
border: "none",
display: "block",
diff --git a/airflow-core/src/airflow/ui/src/pages/Security.test.tsx
b/airflow-core/src/airflow/ui/src/pages/Security.test.tsx
new file mode 100644
index 00000000000..1478f56741f
--- /dev/null
+++ b/airflow-core/src/airflow/ui/src/pages/Security.test.tsx
@@ -0,0 +1,93 @@
+/*!
+ * 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 { fireEvent, render } from "@testing-library/react";
+import type * as ReactRouterDom from "react-router-dom";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { BaseWrapper } from "src/utils/Wrapper";
+
+import { Security } from "./Security";
+
+const navigate = vi.hoisted(() => vi.fn());
+
+vi.mock("react-router-dom", async (importOriginal) => ({
+ ...(await importOriginal<typeof ReactRouterDom>()),
+ useNavigate: () => navigate,
+}));
+
+vi.mock("openapi/queries", () => ({
+ useAuthLinksServiceGetAuthMenus: () => ({
+ data: { authorized_menu_items: [], extra_menu_items: [{ href:
"/auth/users/list/", text: "Users" }] },
+ isLoading: false,
+ }),
+}));
+
+const renderAt = (entry: string) =>
+ render(
+ <MemoryRouter initialEntries={[entry]}>
+ <Routes>
+ <Route element={<Security />} path="security/:page/*" />
+ </Routes>
+ </MemoryRouter>,
+ { wrapper: BaseWrapper },
+ );
+
+const setFrameLocation = (iframe: HTMLIFrameElement | null, pathname: string)
=>
+ Object.defineProperty(iframe, "contentWindow", {
+ configurable: true,
+ value: { location: { hash: "", href: `http://localhost${pathname}`,
pathname, search: "" } },
+ });
+
+beforeEach(() => {
+ navigate.mockClear();
+});
+
+describe("Security view URL sync", () => {
+ it("loads the deep-linked inner auth page when the URL carries one", () => {
+ const { container } = renderAt("/security/users/auth/users/edit/2");
+
+
expect(container.querySelector("iframe")?.getAttribute("src")).toBe("/auth/users/edit/2");
+ });
+
+ it("mirrors in-iframe navigation into the address bar under the security
route", () => {
+ const { container } = renderAt("/security/users");
+ const iframe = container.querySelector("iframe");
+
+ setFrameLocation(iframe, "/auth/users/edit/2");
+ navigate.mockClear();
+ fireEvent.load(iframe as HTMLIFrameElement);
+
+ expect(navigate).toHaveBeenCalledWith(
+ { hash: "", pathname: "/security/users/auth/users/edit/2", search: "" },
+ { replace: true },
+ );
+ });
+
+ it("sends the user home when the framed page leaves the auth area", () => {
+ const { container } = renderAt("/security/users");
+ const iframe = container.querySelector("iframe");
+
+ setFrameLocation(iframe, "/dags");
+ navigate.mockClear();
+ fireEvent.load(iframe as HTMLIFrameElement);
+
+ expect(navigate).toHaveBeenCalledWith("/");
+ });
+});
diff --git a/airflow-core/src/airflow/ui/src/pages/Security.tsx
b/airflow-core/src/airflow/ui/src/pages/Security.tsx
index 743e33a5edc..701601f706d 100644
--- a/airflow-core/src/airflow/ui/src/pages/Security.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Security.tsx
@@ -20,12 +20,13 @@ import { useRef } from "react";
import { Box } from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
-import { useNavigate, useParams } from "react-router-dom";
+import { useParams } from "react-router-dom";
import { useAuthLinksServiceGetAuthMenus } from "openapi/queries";
import { ProgressBar } from "src/system-components";
+import { useIframeUrlSync } from "src/hooks/useIframeUrlSync";
import { useDocumentTitle } from "src/utils";
import { ErrorPage } from "./Error";
@@ -36,6 +37,40 @@ import { ErrorPage } from "./Error";
//
https://airflow.apache.org/docs/apache-airflow/stable/security/security_model.html
const SANDBOX = "allow-scripts allow-same-origin allow-forms";
+const SecurityIframe = ({
+ basePath,
+ href,
+ title,
+}: {
+ readonly basePath: string;
+ readonly href: string;
+ readonly title: string;
+}) => {
+ const iframeRef = useRef<HTMLIFrameElement>(null);
+ const base = new URL(document.baseURI).pathname.replace(/\/$/u, ""); //
Remove trailing slash if exists
+
+ const { initialSrc } = useIframeUrlSync({
+ basePath,
+ enabled: true,
+ entrySrc: href,
+ iframeRef,
+ // The framed auth pages all live under `/auth/`. If navigation escapes it
(e.g. a link back into
+ // the main app), the hook sends the user home instead of rendering the
React app (and its own
+ // navigation sidebar, which would produce a duplicate nav bar) inside the
iframe.
+ isAllowedPath: (pathname) => pathname.startsWith(`${base}/auth/`),
+ });
+
+ return (
+ <iframe
+ ref={iframeRef}
+ sandbox={SANDBOX}
+ src={initialSrc}
+ style={{ height: "100%", width: "100%" }}
+ title={title}
+ />
+ );
+};
+
export const Security = () => {
const { page } = useParams();
const { t: translate } = useTranslation();
@@ -46,31 +81,6 @@ export const Security = () => {
const link = authLinks?.extra_menu_items.find((mi) =>
mi.text.toLowerCase().replace(" ", "-") === page);
- const navigate = useNavigate();
- // Track when we are already redirecting so that setting iframe.src =
"about:blank"
- // (which fires another onLoad event) does not trigger a second navigate
call.
- const isRedirecting = useRef(false);
-
- const onLoad = () => {
- if (isRedirecting.current) {
- return;
- }
-
- const iframe: HTMLIFrameElement | null =
document.querySelector("#security-iframe");
-
- if (iframe?.contentWindow) {
- const base = new URL(document.baseURI).pathname.replace(/\/$/u, ""); //
Remove trailing slash if exists
-
- if (!iframe.contentWindow.location.pathname.startsWith(`${base}/auth/`))
{
- // Clear the iframe immediately so that the React app does not render
its own
- // navigation sidebar inside the iframe, which would produce a
duplicate nav bar.
- isRedirecting.current = true;
- iframe.src = "about:blank";
- void navigate("/");
- }
- }
- };
-
if (!link) {
if (isLoading) {
return (
@@ -85,17 +95,7 @@ export const Security = () => {
return (
<Box flexGrow={1} m={-3}>
- {
- // eslint-disable-next-line
jsx-a11y/no-noninteractive-element-interactions
- <iframe
- id="security-iframe"
- onLoad={onLoad}
- sandbox={SANDBOX}
- src={link.href}
- style={{ height: "100%", width: "100%" }}
- title={link.text}
- />
- }
+ <SecurityIframe basePath={`/security/${page ?? ""}`} href={link.href}
key={page} title={link.text} />
</Box>
);
};
diff --git a/airflow-core/src/airflow/ui/src/router.tsx
b/airflow-core/src/airflow/ui/src/router.tsx
index 7ad24a4f43f..be1befc2c9b 100644
--- a/airflow-core/src/airflow/ui/src/router.tsx
+++ b/airflow-core/src/airflow/ui/src/router.tsx
@@ -188,7 +188,7 @@ export const routerConfig = [
},
{
element: <Security />,
- path: "security/:page",
+ path: "security/:page/*",
},
{
element: <Connections />,
diff --git
a/airflow-core/src/airflow/ui/tests/e2e/specs/iframe-url-sync.spec.ts
b/airflow-core/src/airflow/ui/tests/e2e/specs/iframe-url-sync.spec.ts
new file mode 100644
index 00000000000..64622e0dc46
--- /dev/null
+++ b/airflow-core/src/airflow/ui/tests/e2e/specs/iframe-url-sync.spec.ts
@@ -0,0 +1,86 @@
+/*!
+ * 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 type { Page } from "@playwright/test";
+
+import { expect, test } from "tests/e2e/fixtures";
+
+const page = (heading: string, links: Array<{ href: string; id: string }> =
[]) =>
+ `<html><body><h1>${heading}</h1>${links.map((lnk) => `<a href="${lnk.href}"
id="${lnk.id}">${lnk.id}</a>`).join("")}</body></html>`;
+
+// Stub the framed same-origin app with real (200) pages, so the sync is
exercised regardless of the
+// auth manager / plugins the backend happens to run.
+const stubFramedApp = async (browserPage: Page, pages: Record<string, string>)
=> {
+ await browserPage.route(
+ (url) => Object.keys(pages).includes(url.pathname),
+ (route) =>
+ route.fulfill({ body: pages[new URL(route.request().url()).pathname],
contentType: "text/html" }),
+ );
+};
+
+test.describe("Iframe view URL sync (#55815)", () => {
+ test("security views mirror in-iframe navigation into the address bar and
deep-link", async ({
+ page: browserPage,
+ }) => {
+ await stubFramedApp(browserPage, {
+ "/auth/users/edit/2": page("Edit user 2"),
+ "/auth/users/list/": page("List Users", [{ href: "/auth/users/edit/2",
id: "edit" }]),
+ });
+
+ await browserPage.goto("/security/users");
+ const frame = browserPage.frameLocator("iframe");
+
+ await expect(frame.locator("h1")).toHaveText("List Users");
+ await
expect(browserPage).toHaveURL(/\/security\/users\/auth\/users\/list\/$/u);
+
+ await frame.locator("#edit").click();
+ await expect(frame.locator("h1")).toHaveText("Edit user 2");
+ await
expect(browserPage).toHaveURL(/\/security\/users\/auth\/users\/edit\/2$/u);
+
+ await browserPage.evaluate(() => window.history.back());
+ await expect(frame.locator("h1")).toHaveText("List Users");
+ await
expect(browserPage).toHaveURL(/\/security\/users\/auth\/users\/list\/$/u);
+
+ // Deep-link straight to the edit page.
+ await browserPage.goto("/security/users/auth/users/edit/2");
+ await
expect(browserPage.frameLocator("iframe").locator("h1")).toHaveText("Edit user
2");
+ await
expect(browserPage).toHaveURL(/\/security\/users\/auth\/users\/edit\/2$/u);
+ });
+
+ test("legacy plugin views mirror in-iframe navigation into the address bar
and deep-link", async ({
+ page: browserPage,
+ }) => {
+ await stubFramedApp(browserPage, {
+ "/pluginsv2/": page("Home", [{ href: "/pluginsv2/some/inner/page", id:
"inner" }]),
+ "/pluginsv2/some/inner/page": page("Inner page"),
+ });
+
+ await browserPage.goto("/plugin/legacy-fab-views");
+ const frame = browserPage.frameLocator("iframe");
+
+ await expect(frame.locator("h1")).toHaveText("Home");
+ await
expect(browserPage).toHaveURL(/\/plugin\/legacy-fab-views\/pluginsv2\/$/u);
+
+ await frame.locator("#inner").click();
+ await expect(frame.locator("h1")).toHaveText("Inner page");
+ await
expect(browserPage).toHaveURL(/\/plugin\/legacy-fab-views\/pluginsv2\/some\/inner\/page$/u);
+
+ await
browserPage.goto("/plugin/legacy-fab-views/pluginsv2/some/inner/page");
+ await
expect(browserPage.frameLocator("iframe").locator("h1")).toHaveText("Inner
page");
+ });
+});