Skip to content

Commit 15df1f0

Browse files
committed
refactor: structure mocks in tests differently
1 parent d03cfd6 commit 15df1f0

1 file changed

Lines changed: 105 additions & 84 deletions

File tree

airflow/opa-auth-manager/airflow-3/tests/test_opa_fab_auth_manager.py

Lines changed: 105 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
# Then we could run these tests against the Airflow instance and use the Airflow API to
77
# actually test the effect of Rego policies on user authorization.
88
#
9+
from types import SimpleNamespace
910
from unittest import mock
1011
from unittest.mock import Mock
1112

1213
import pytest
1314
from airflow.api_fastapi.auth.managers.models.resource_details import (
14-
AccessView,
1515
DagAccessEntity,
1616
DagDetails,
1717
)
@@ -57,6 +57,40 @@ def auth_manager_with_appbuilder(flask_app):
5757
return auth_manager
5858

5959

60+
@pytest.fixture
61+
def mock_opa(monkeypatch):
62+
"""
63+
Replace the OPA HTTP boundary (``call_opa``) so tests exercise the real
64+
``is_authorized_*`` → ``_is_authorized_in_opa`` → ``OpaInput`` chain
65+
without making network calls.
66+
67+
Set ``mock_opa.decide = lambda endpoint, body: bool`` to drive per-request
68+
decisions. Default returns ``False`` (deny). Recorded ``(endpoint, body)``
69+
pairs are available as ``mock_opa.calls``.
70+
"""
71+
state = SimpleNamespace(
72+
decide=lambda endpoint, body: False,
73+
calls=[],
74+
)
75+
76+
def fake_call_opa(self, url, json, timeout):
77+
endpoint = url.rsplit("/", 1)[-1]
78+
state.calls.append((endpoint, json))
79+
response = Mock()
80+
response.json.return_value = {"result": state.decide(endpoint, json)}
81+
return response
82+
83+
monkeypatch.setattr(OpaFabAuthManager, "call_opa", fake_call_opa)
84+
return state
85+
86+
87+
def _make_user(name="jane.doe", user_id="1"):
88+
user = Mock()
89+
user.get_id.return_value = user_id
90+
user.get_name.return_value = name
91+
return user
92+
93+
6094
class TestOpaFabAuthManager:
6195
def test_init_wires_opa_cache_for_fastapi_apiserver(self):
6296
# The FastAPI api-server calls auth_manager.init() instead of
@@ -243,53 +277,51 @@ def test_is_authorized_dag(
243277
assert result == expected_result
244278

245279
def test_get_authorized_dag_ids_uses_opa_not_fab_db(
246-
self, auth_manager_with_appbuilder
280+
self, auth_manager_with_appbuilder, mock_opa
247281
):
248282
# Repro for the OPA listing bug: a user with no FAB permissions
249283
# (e.g. the default Public role) must still see the DAGs that OPA
250284
# allows. The FabAuthManager base override would return set() here
251285
# because it reads roles from the metadata DB.
252-
user = Mock()
253-
user.id = 1
254-
user.perms = []
286+
user = _make_user()
255287

256-
all_dag_ids = {"allowed_dag", "denied_dag"}
257288
session = Mock()
258-
session.execute.return_value = [Mock(dag_id=dag_id) for dag_id in all_dag_ids]
289+
session.execute.return_value = [
290+
Mock(dag_id="allowed_dag"),
291+
Mock(dag_id="denied_dag"),
292+
]
259293

260-
def fake_is_authorized_dag(*, method, details=None, access_entity=None, user):
261-
return details is not None and details.id == "allowed_dag"
294+
mock_opa.decide = lambda endpoint, body: (
295+
endpoint == "is_authorized_dag"
296+
and body["input"]["details"]["id"] == "allowed_dag"
297+
)
262298

263-
with mock.patch.object(
264-
auth_manager_with_appbuilder,
265-
"is_authorized_dag",
266-
side_effect=fake_is_authorized_dag,
267-
):
268-
result = auth_manager_with_appbuilder.get_authorized_dag_ids(
269-
user=user, method="GET", session=session
270-
)
299+
result = auth_manager_with_appbuilder.get_authorized_dag_ids(
300+
user=user, method="GET", session=session
301+
)
271302

272303
assert result == {"allowed_dag"}
304+
# Every DAG id was offered to OPA — confirms per-item delegation
305+
# rather than a global FAB role lookup.
306+
asked = {body["input"]["details"]["id"] for _, body in mock_opa.calls}
307+
assert asked == {"allowed_dag", "denied_dag"}
273308

274309
def test_get_authorized_dag_ids_provides_session_when_caller_omits_it(
275-
self, auth_manager_with_appbuilder
310+
self, auth_manager_with_appbuilder, mock_opa
276311
):
277312
# Real callers (api_fastapi/core_api/security.py) don't pass `session`.
278313
# Our override must rely on @provide_session to inject one; previously
279314
# it forwarded the default NEW_SESSION (None) and crashed with
280315
# 'NoneType' has no attribute 'execute'.
281-
user = Mock()
282-
user.perms = []
316+
user = _make_user()
283317

284318
session = Mock()
285319
session.execute.return_value = [Mock(dag_id="allowed_dag")]
320+
mock_opa.decide = lambda endpoint, body: True
286321

287-
with (
288-
mock.patch("airflow.utils.session.create_session") as mock_create_session,
289-
mock.patch.object(
290-
auth_manager_with_appbuilder, "is_authorized_dag", return_value=True
291-
),
292-
):
322+
with mock.patch(
323+
"airflow.utils.session.create_session"
324+
) as mock_create_session:
293325
mock_create_session.return_value.__enter__.return_value = session
294326
result = auth_manager_with_appbuilder.get_authorized_dag_ids(
295327
user=user, method="GET"
@@ -299,95 +331,84 @@ def test_get_authorized_dag_ids_provides_session_when_caller_omits_it(
299331
mock_create_session.assert_called_once()
300332

301333
@pytest.mark.parametrize(
302-
"menu_item, expected_method, expected_kwargs",
334+
"menu_item, expected_endpoint, expected_input_subset",
303335
[
304336
(MenuItem.ASSETS, "is_authorized_asset", {"method": "GET"}),
305337
(
306338
MenuItem.AUDIT_LOG,
307339
"is_authorized_dag",
308-
{"method": "GET", "access_entity": DagAccessEntity.AUDIT_LOG},
340+
{"method": "GET", "access_entity": "AUDIT_LOG"},
309341
),
310342
(MenuItem.CONFIG, "is_authorized_configuration", {"method": "GET"}),
311343
(MenuItem.CONNECTIONS, "is_authorized_connection", {"method": "GET"}),
312-
(MenuItem.DAGS, "is_authorized_dag", {"method": "GET"}),
313-
(MenuItem.DOCS, "is_authorized_view", {"access_view": AccessView.DOCS}),
314344
(
315-
MenuItem.PLUGINS,
316-
"is_authorized_view",
317-
{"access_view": AccessView.PLUGINS},
345+
MenuItem.DAGS,
346+
"is_authorized_dag",
347+
{"method": "GET", "access_entity": None},
318348
),
349+
(MenuItem.DOCS, "is_authorized_view", {"access_view": "DOCS"}),
350+
(MenuItem.PLUGINS, "is_authorized_view", {"access_view": "PLUGINS"}),
319351
(MenuItem.POOLS, "is_authorized_pool", {"method": "GET"}),
320-
(
321-
MenuItem.PROVIDERS,
322-
"is_authorized_view",
323-
{"access_view": AccessView.PROVIDERS},
324-
),
352+
(MenuItem.PROVIDERS, "is_authorized_view", {"access_view": "PROVIDERS"}),
325353
(MenuItem.VARIABLES, "is_authorized_variable", {"method": "GET"}),
326354
(
327355
MenuItem.XCOMS,
328356
"is_authorized_dag",
329-
{"method": "GET", "access_entity": DagAccessEntity.XCOM},
357+
{"method": "GET", "access_entity": "XCOM"},
330358
),
331359
],
332360
)
333361
def test_filter_authorized_menu_items_routes_through_opa(
334362
self,
335363
menu_item,
336-
expected_method,
337-
expected_kwargs,
364+
expected_endpoint,
365+
expected_input_subset,
338366
auth_manager_with_appbuilder,
367+
mock_opa,
339368
):
340-
# Each MenuItem must trigger the matching OPA-backed is_authorized_*
341-
# call, so menu visibility is OPA-driven rather than FAB-DB-driven.
342-
user = Mock()
343-
user.perms = []
369+
# Each MenuItem must trigger a request to the matching OPA endpoint
370+
# with the expected input, so menu visibility is OPA-driven rather
371+
# than FAB-DB-driven, and the Rego wire contract is documented.
372+
user = _make_user()
344373

345-
with mock.patch.object(
346-
auth_manager_with_appbuilder, expected_method, return_value=True
347-
) as mocked:
348-
allowed = auth_manager_with_appbuilder.filter_authorized_menu_items(
349-
[menu_item], user=user
350-
)
374+
mock_opa.decide = lambda endpoint, body: True
375+
allowed = auth_manager_with_appbuilder.filter_authorized_menu_items(
376+
[menu_item], user=user
377+
)
351378
assert allowed == [menu_item]
352-
mocked.assert_called_once_with(user=user, **expected_kwargs)
353379

354-
with mock.patch.object(
355-
auth_manager_with_appbuilder, expected_method, return_value=False
356-
):
357-
denied = auth_manager_with_appbuilder.filter_authorized_menu_items(
358-
[menu_item], user=user
359-
)
380+
assert len(mock_opa.calls) == 1
381+
endpoint, body = mock_opa.calls[0]
382+
assert endpoint == expected_endpoint
383+
assert expected_input_subset.items() <= body["input"].items()
384+
385+
# Deny path: a fresh OPA decision actually filters the item out,
386+
# proving the dispatch consults OPA rather than always allowing.
387+
auth_manager_with_appbuilder.opa_cache.clear()
388+
mock_opa.decide = lambda endpoint, body: False
389+
denied = auth_manager_with_appbuilder.filter_authorized_menu_items(
390+
[menu_item], user=user
391+
)
360392
assert denied == []
361393

362394
def test_filter_authorized_menu_items_preserves_order_and_filters(
363-
self, auth_manager_with_appbuilder
395+
self, auth_manager_with_appbuilder, mock_opa
364396
):
365-
user = Mock()
366-
user.perms = []
397+
user = _make_user()
367398

368-
def fake_is_authorized_dag(*, method, details=None, access_entity=None, user):
369-
return access_entity is None
399+
def decide(endpoint, body):
400+
if endpoint == "is_authorized_dag":
401+
# Allow DAGs root menu, deny the XCOM access entity.
402+
return body["input"].get("access_entity") is None
403+
if endpoint == "is_authorized_connection":
404+
return True
405+
return False
370406

371-
with (
372-
mock.patch.object(
373-
auth_manager_with_appbuilder,
374-
"is_authorized_dag",
375-
side_effect=fake_is_authorized_dag,
376-
),
377-
mock.patch.object(
378-
auth_manager_with_appbuilder,
379-
"is_authorized_connection",
380-
return_value=True,
381-
),
382-
mock.patch.object(
383-
auth_manager_with_appbuilder,
384-
"is_authorized_view",
385-
return_value=False,
386-
),
387-
):
388-
result = auth_manager_with_appbuilder.filter_authorized_menu_items(
389-
[MenuItem.DAGS, MenuItem.DOCS, MenuItem.CONNECTIONS, MenuItem.XCOMS],
390-
user=user,
391-
)
407+
mock_opa.decide = decide
408+
409+
result = auth_manager_with_appbuilder.filter_authorized_menu_items(
410+
[MenuItem.DAGS, MenuItem.DOCS, MenuItem.CONNECTIONS, MenuItem.XCOMS],
411+
user=user,
412+
)
392413

393414
assert result == [MenuItem.DAGS, MenuItem.CONNECTIONS]

0 commit comments

Comments
 (0)