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");
+  });
+});

Reply via email to