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,127 @@ 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+ configure_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+ # Configure testing bundle with tmp_path
527+ with configure_testing_dag_bundle (tmp_path ):
528+ # Get the actual bundle object
529+ manager = DagBundlesManager ()
530+ bundle = manager .get_bundle ("testing" )
531+ assert bundle is not None
532+
533+ # Create a DAG file with import error (file that fails to import, no DAG created)
534+ error_file = bundle .path / "error_file.py"
535+ error_file .write_text (
536+ """from datetime import datetime, timedelta
537+
538+ # Operators
539+ from airflow.providers.standard.operators.bash import BashOperator
540+
541+ # The DAG object
542+ from airflow.sdk import DAG
543+
544+ with DAG(
545+ "import_error_test",
546+ description="DAG with intentional import errors",
547+ schedule_NOEXIST_KEYWORD=timedelta(days=1),
548+ start_date=datetime(2021, 1, 1),
549+ catchup=False,
550+ tags=["example", "error"],
551+ ) as dag:
552+ # This task will never be created due to import error above
553+ t1 = BashOperator(
554+ task_id="print_date",
555+ bash_command="date",
556+ )
557+ """
558+ )
559+
560+ # Parse the file using BundleDagBag
561+ bundle_dagbag = BundleDagBag (
562+ dag_folder = error_file ,
563+ bundle_path = bundle .path ,
564+ bundle_name = bundle .name ,
565+ )
566+ bundle_dagbag .collect_dags ()
567+
568+ # Verify import error was captured
569+ assert len (bundle_dagbag .import_errors ) > 0
570+
571+ # Convert import_errors to the format expected by update_dag_parsing_results_in_db
572+ import_errors_dict = {}
573+ for filepath , error_msg in bundle_dagbag .import_errors .items ():
574+ file_path = Path (filepath )
575+ bundle_path = Path (bundle .path )
576+ try :
577+ relative_path = str (file_path .relative_to (bundle_path ))
578+ except ValueError :
579+ relative_path = file_path .name
580+ import_errors_dict [(bundle .name , relative_path )] = error_msg
581+
582+ # Update DB with parsing results
583+ update_dag_parsing_results_in_db (
584+ bundle_name = bundle .name ,
585+ bundle_version = None ,
586+ dags = [],
587+ import_errors = import_errors_dict ,
588+ parse_duration = None ,
589+ warnings = set (),
590+ session = session ,
591+ files_parsed = {(bundle .name , rel_path ) for _ , rel_path in import_errors_dict .keys ()},
592+ )
593+ session .commit ()
594+
595+ # Verify import error was stored in DB
596+ db_import_errors = session .scalars (
597+ select (ParseImportError ).where (ParseImportError .bundle_name == bundle .name )
598+ ).all ()
599+ assert len (db_import_errors ) > 0
600+
601+ # User has access to a DAG in the bundle
602+ set_mock_auth_manager__get_authorized_dag_ids (mock_get_auth_manager , {permitted_dag_model .dag_id })
603+
604+ # Test GET /importErrors/{id} - should return the import error
605+ import_error_id = db_import_errors [0 ].id
606+ response = test_client .get (f"/importErrors/{ import_error_id } " )
607+
608+ assert response .status_code == 200
609+ response_json = response .json ()
610+ assert response_json ["import_error_id" ] == import_error_id
611+ assert response_json ["bundle_name" ] == bundle .name
612+ assert (
613+ "schedule_NOEXIST_KEYWORD" in response_json ["stack_trace" ]
614+ or "TypeError" in response_json ["stack_trace" ]
615+ or "ImportError" in response_json ["stack_trace" ]
616+ )
617+
618+ # Test GET /importErrors - should include the import error in the list
619+ response_list = test_client .get ("/importErrors" )
620+ assert response_list .status_code == 200
621+ response_list_json = response_list .json ()
622+ assert response_list_json ["total_entries" ] > 0
623+ filenames = [ie ["filename" ] for ie in response_list_json ["import_errors" ]]
624+ assert any ("error_file" in filename for filename in filenames )
0 commit comments