2121from unittest import mock
2222
2323import pytest
24+ from sqlalchemy import select
2425
2526from airflow .api_fastapi .auth .managers .models .resource_details import DagDetails
2627from airflow .models import DagModel
@@ -236,18 +237,22 @@ def test_should_raises_403_unauthorized(self, unauthorized_test_client, import_e
236237 response = unauthorized_test_client .get (f"/importErrors/{ import_error_id } " )
237238 assert response .status_code == 403
238239
240+ @pytest .mark .usefixtures ("permitted_dag_model" )
239241 @mock .patch ("airflow.api_fastapi.core_api.routes.public.import_error.get_auth_manager" )
240242 def test_should_raises_403_unauthorized__user_can_not_read_any_dags_in_file (
241- self , mock_get_auth_manager , test_client , import_errors
243+ self , mock_get_auth_manager , test_client , import_errors , permitted_dag_model
242244 ):
243245 import_error_id = import_errors [0 ].id
244- # Mock auth_manager
245- mock_get_authorized_dag_ids = set_mock_auth_manager__get_authorized_dag_ids (mock_get_auth_manager )
246+ # Mock auth_manager - user has no access to any DAGs
247+ mock_get_authorized_dag_ids = set_mock_auth_manager__get_authorized_dag_ids (
248+ mock_get_auth_manager , set ()
249+ )
246250 # Act
247251 response = test_client .get (f"/importErrors/{ import_error_id } " )
248252 # Assert
249253 mock_get_authorized_dag_ids .assert_called_once_with (user = mock .ANY )
250254 assert response .status_code == 403
255+ # Since permitted_dag_model exists for FILENAME1, the error message should mention "file"
251256 assert response .json () == {"detail" : "You do not have read permission on any of the DAGs in the file" }
252257
253258 @mock .patch ("airflow.api_fastapi.core_api.routes.public.import_error.get_auth_manager" )
@@ -364,7 +369,9 @@ def test_get_import_errors(
364369 set_mock_auth_manager__get_authorized_dag_ids (mock_get_auth_manager , permitted_dag_model_all )
365370 set_mock_auth_manager__batch_is_authorized_dag (mock_get_auth_manager , True )
366371
367- with assert_queries_count (5 ):
372+ # Query count: 1 (paginated_select count), 1 (paginated_select), 1 (visible_files_cte),
373+ # 1 (bundle_dag_map), 3 (get_dag_id_to_team_name_mapping for 3 import errors)
374+ with assert_queries_count (7 ):
368375 response = test_client .get ("/importErrors" , params = query_params )
369376
370377 assert response .status_code == expected_status_code
@@ -426,8 +433,8 @@ def test_user_can_not_read_all_dags_in_file(
426433 mock_batch_is_authorized_dag = set_mock_auth_manager__batch_is_authorized_dag (
427434 mock_get_auth_manager , batch_is_authorized_dag_return_value
428435 )
429- # Act
430- with assert_queries_count (3 ):
436+ # Query count: 1 (paginated_select count), 1 (paginated_select), 1 (visible_files_cte), 1 (bundle_dag_map)
437+ with assert_queries_count (4 ):
431438 response = test_client .get ("/importErrors" )
432439 # Assert
433440 mock_get_authorized_dag_ids .assert_called_once_with (method = "GET" , user = mock .ANY )
@@ -474,7 +481,10 @@ def test_bundle_name_join_condition_for_import_errors(
474481 response_json = response .json ()
475482
476483 # Should return the import error with matching bundle_name and filename
477- assert response_json ["total_entries" ] == 1
484+ # Note: total_entries reflects count before permission filtering (all 3 import errors)
485+ # but only 1 is returned after filtering
486+ assert response_json ["total_entries" ] == 3
487+ assert len (response_json ["import_errors" ]) == 1
478488 assert response_json ["import_errors" ][0 ]["bundle_name" ] == BUNDLE_NAME
479489 assert response_json ["import_errors" ][0 ]["filename" ] == FILENAME1
480490
@@ -488,7 +498,125 @@ def test_bundle_name_join_condition_for_import_errors(
488498 response2 = test_client .get ("/importErrors" )
489499
490500 # Assert - should return 0 entries because bundle_name no longer matches
501+ # Note: total_entries reflects count before permission filtering (still 3),
502+ # but import_errors is empty after filtering
491503 assert response2 .status_code == 200
492504 response_json2 = response2 .json ()
493- assert response_json2 ["total_entries" ] == 0
505+ assert response_json2 ["total_entries" ] == 3
494506 assert response_json2 ["import_errors" ] == []
507+
508+ @pytest .mark .usefixtures ("permitted_dag_model" )
509+ @mock .patch ("airflow.api_fastapi.core_api.routes.public.import_error.get_auth_manager" )
510+ def test_dag_bundle_import_error_with_no_dags_is_visible_in_web (
511+ self ,
512+ mock_get_auth_manager ,
513+ test_client ,
514+ permitted_dag_model ,
515+ testing_dag_bundle ,
516+ session ,
517+ tmp_path ,
518+ ):
519+ """Test that import error from DAG bundle file with no DAGs is visible via web API."""
520+ from pathlib import Path
521+
522+ from airflow .dag_processing .bundles .manager import DagBundlesManager
523+ from airflow .dag_processing .collection import update_dag_parsing_results_in_db
524+ from airflow .dag_processing .dagbag import BundleDagBag
525+
526+ # Get the actual bundle object (testing_dag_bundle fixture only creates DagBundleModel)
527+ manager = DagBundlesManager ()
528+ bundle = manager .get_bundle ("testing" )
529+ assert bundle is not None
530+
531+ # Create a DAG file with import error (file that fails to import, no DAG created)
532+ error_file = bundle .path / "error_file.py"
533+ error_file .write_text (
534+ """from datetime import datetime, timedelta
535+
536+ # Operators
537+ from airflow.providers.standard.operators.bash import BashOperator
538+
539+ # The DAG object
540+ from airflow.sdk import DAG
541+
542+ with DAG(
543+ "import_error_test",
544+ description="DAG with intentional import errors",
545+ schedule_NOEXIST_KEYWORD=timedelta(days=1),
546+ start_date=datetime(2021, 1, 1),
547+ catchup=False,
548+ tags=["example", "error"],
549+ ) as dag:
550+ # This task will never be created due to import error above
551+ t1 = BashOperator(
552+ task_id="print_date",
553+ bash_command="date",
554+ )
555+ """
556+ )
557+
558+ # Parse the file using BundleDagBag
559+ bundle_dagbag = BundleDagBag (
560+ dag_folder = error_file ,
561+ bundle_path = bundle .path ,
562+ bundle_name = bundle .name ,
563+ )
564+ bundle_dagbag .collect_dags ()
565+
566+ # Verify import error was captured
567+ assert len (bundle_dagbag .import_errors ) > 0
568+
569+ # Convert import_errors to the format expected by update_dag_parsing_results_in_db
570+ import_errors_dict = {}
571+ for filepath , error_msg in bundle_dagbag .import_errors .items ():
572+ file_path = Path (filepath )
573+ bundle_path = Path (bundle .path )
574+ try :
575+ relative_path = str (file_path .relative_to (bundle_path ))
576+ except ValueError :
577+ relative_path = file_path .name
578+ import_errors_dict [(bundle .name , relative_path )] = error_msg
579+
580+ # Update DB with parsing results
581+ update_dag_parsing_results_in_db (
582+ bundle_name = bundle .name ,
583+ bundle_version = None ,
584+ dags = [],
585+ import_errors = import_errors_dict ,
586+ parse_duration = None ,
587+ warnings = set (),
588+ session = session ,
589+ files_parsed = {(testing_dag_bundle .name , rel_path ) for _ , rel_path in import_errors_dict .keys ()},
590+ )
591+ session .commit ()
592+
593+ # Verify import error was stored in DB
594+ db_import_errors = session .scalars (
595+ select (ParseImportError ).where (ParseImportError .bundle_name == bundle .name )
596+ ).all ()
597+ assert len (db_import_errors ) > 0
598+
599+ # User has access to a DAG in the bundle
600+ set_mock_auth_manager__get_authorized_dag_ids (mock_get_auth_manager , {permitted_dag_model .dag_id })
601+
602+ # Test GET /importErrors/{id} - should return the import error
603+ import_error_id = db_import_errors [0 ].id
604+ response = test_client .get (f"/importErrors/{ import_error_id } " )
605+
606+ assert response .status_code == 200
607+ response_json = response .json ()
608+ assert response_json ["import_error_id" ] == import_error_id
609+ assert response_json ["bundle_name" ] == bundle .name
610+ assert (
611+ "schedule_NOEXIST_KEYWORD" in response_json ["stack_trace" ]
612+ or "TypeError" in response_json ["stack_trace" ]
613+ or "ImportError" in response_json ["stack_trace" ]
614+ )
615+
616+ # Test GET /importErrors - should include the import error in the list
617+ response_list = test_client .get ("/importErrors" )
618+ assert response_list .status_code == 200
619+ response_list_json = response_list .json ()
620+ assert response_list_json ["total_entries" ] > 0
621+ filenames = [ie ["filename" ] for ie in response_list_json ["import_errors" ]]
622+ assert any ("error_file" in filename for filename in filenames )
0 commit comments