This is an automated email from the ASF dual-hosted git repository.
o-nikolas 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 b699f1c8f9f Test team scoping of plugin UI views in multi-team mode
(#71805)
b699f1c8f9f is described below
commit b699f1c8f9f25740822711c0b22cebb1e09b98ea
Author: Niko Oliveira <[email protected]>
AuthorDate: Wed Aug 19 16:07:18 2026 -0700
Test team scoping of plugin UI views in multi-team mode (#71805)
Plugin nav items, external views, and React apps are served to the UI
through GET /api/v2/plugins, which already filters plugins by the user's
authorized teams when multi-team mode is enabled. Because external_views
and react_apps are nested in each plugin's response, they are scoped along
with their plugin — a team-scoped plugin's views and apps are never sent to
users outside that team.
That guarantee had no direct test coverage (existing tests used plugins
without views). This adds TestGetPluginsUIViewTeamFiltering, which verifies
that a user sees only their teams' (plus global) external views and React
apps, that a user with no teams sees only global ones, and that all views
are returned when multi-team mode is disabled.
---
.../core_api/routes/public/test_plugins.py | 105 +++++++++++++++++++++
1 file changed, 105 insertions(+)
diff --git
a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py
b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py
index 38d9388b213..2290438faf9 100644
--- a/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py
+++ b/airflow-core/tests/unit/api_fastapi/core_api/routes/public/test_plugins.py
@@ -322,3 +322,108 @@ class TestGetPluginsTeamFiltering:
team_name_by_plugin = {p["name"]: p["team_name"] for p in
response.json()["plugins"]}
assert team_name_by_plugin == {"global_plugin": None, "team_a_plugin":
"team_a"}
+
+
+def _make_view_plugin(name: str, team_name: str | None) -> AirflowPlugin:
+ """Build a plugin instance carrying one external view and one react app.
+
+ Uses per-instance lists (not class attributes) so the dedup/validation
pass in
+ ``_get_ui_plugins`` cannot mutate shared state across tests.
+ """
+ plugin = AirflowPlugin()
+ plugin.name = name
+ plugin.team_name = team_name
+ plugin.external_views = [
+ {
+ "name": f"{name} view",
+ "href": f"https://example.com/{name}",
+ "url_route": f"{name}_view",
+ "destination": "nav",
+ }
+ ]
+ plugin.react_apps = [
+ {
+ "name": f"{name} app",
+ "bundle_url": f"https://example.com/{name}.js",
+ "url_route": f"{name}_app",
+ "destination": "nav",
+ }
+ ]
+ return plugin
+
+
+@skip_if_force_lowest_dependencies_marker
+class TestGetPluginsUIViewTeamFiltering:
+ """The React UI builds its nav items, external-view tabs, and react apps
from the
+ ``external_views``/``react_apps`` nested in the /api/v2/plugins response.
Because a
+ team-scoped plugin's whole entry is filtered out for non-team users, its
UI views
+ and apps must be absent from the response as well.
+ """
+
+ def _plugins(self) -> list[AirflowPlugin]:
+ return [
+ _make_view_plugin("global_view_plugin", None),
+ _make_view_plugin("team_a_view_plugin", "team_a"),
+ _make_view_plugin("team_b_view_plugin", "team_b"),
+ ]
+
+ @staticmethod
+ def _view_routes(plugins: list[dict]) -> set[str]:
+ return {view["url_route"] for plugin in plugins for view in
plugin["external_views"]}
+
+ @staticmethod
+ def _app_routes(plugins: list[dict]) -> set[str]:
+ return {app["url_route"] for plugin in plugins for app in
plugin["react_apps"]}
+
+ @conf_vars({("core", "multi_team"): "True"})
+
@mock.patch("airflow.api_fastapi.core_api.routes.public.plugins.get_auth_manager")
+ def test_views_and_apps_scoped_to_authorized_teams(self,
mock_get_auth_manager, test_client):
+ """A team_a user sees global + team_a views/apps, but never
team_b's."""
+ mock_get_auth_manager.return_value.get_authorized_teams.return_value =
{"team_a"}
+
+ with mock_plugin_manager(plugins=self._plugins()):
+ response = test_client.get("/plugins")
+
+ assert response.status_code == 200
+ plugins = response.json()["plugins"]
+
+ view_routes = self._view_routes(plugins)
+ assert "global_view_plugin_view" in view_routes
+ assert "team_a_view_plugin_view" in view_routes
+ assert "team_b_view_plugin_view" not in view_routes
+
+ app_routes = self._app_routes(plugins)
+ assert "global_view_plugin_app" in app_routes
+ assert "team_a_view_plugin_app" in app_routes
+ assert "team_b_view_plugin_app" not in app_routes
+
+ @conf_vars({("core", "multi_team"): "True"})
+
@mock.patch("airflow.api_fastapi.core_api.routes.public.plugins.get_auth_manager")
+ def test_user_with_no_teams_sees_only_global_views_and_apps(self,
mock_get_auth_manager, test_client):
+ mock_get_auth_manager.return_value.get_authorized_teams.return_value =
set()
+
+ with mock_plugin_manager(plugins=self._plugins()):
+ response = test_client.get("/plugins")
+
+ assert response.status_code == 200
+ plugins = response.json()["plugins"]
+ assert self._view_routes(plugins) == {"global_view_plugin_view"}
+ assert self._app_routes(plugins) == {"global_view_plugin_app"}
+
+ def test_no_view_filtering_when_multi_team_disabled(self, test_client):
+ """With multi_team off, every plugin's views/apps are returned."""
+ with mock_plugin_manager(plugins=self._plugins()):
+ response = test_client.get("/plugins")
+
+ assert response.status_code == 200
+ plugins = response.json()["plugins"]
+ assert {
+ "global_view_plugin_view",
+ "team_a_view_plugin_view",
+ "team_b_view_plugin_view",
+ } <= self._view_routes(plugins)
+ assert {
+ "global_view_plugin_app",
+ "team_a_view_plugin_app",
+ "team_b_view_plugin_app",
+ } <= self._app_routes(plugins)