diff --git a/.changes/unreleased/breaking-20260329-150739.yaml b/.changes/unreleased/breaking-20260329-150739.yaml new file mode 100644 index 00000000..b890ea37 --- /dev/null +++ b/.changes/unreleased/breaking-20260329-150739.yaml @@ -0,0 +1,8 @@ +kind: breaking +body: Remove default credential fallback; explicit token credential is now required +time: 2026-03-29T15:07:39.9569937+03:00 +custom: + Author: shirasassoon + AuthorLink: https://github.com/shirasassoon + Issue: "909" + IssueLink: https://github.com/microsoft/fabric-cicd/issues/909 diff --git a/src/fabric_cicd/fabric_workspace.py b/src/fabric_cicd/fabric_workspace.py index 426d6da4..4c761d6a 100644 --- a/src/fabric_cicd/fabric_workspace.py +++ b/src/fabric_cicd/fabric_workspace.py @@ -35,7 +35,7 @@ def __init__( environment: str = "N/A", workspace_id: Optional[str] = None, workspace_name: Optional[str] = None, - token_credential: TokenCredential = None, + token_credential: Optional[TokenCredential] = None, **kwargs, ) -> None: """ @@ -47,7 +47,7 @@ def __init__( repository_directory: Local directory path of the repository where items are to be deployed from. item_type_in_scope: Item types that should be deployed for a given workspace. If omitted, defaults to all available item types. environment: The environment to be used for parameterization. - token_credential: The token credential to use for API requests. + token_credential: The token credential to use for API requests (e.g., AzureCliCredential, ClientSecretCredential). kwargs: Additional keyword arguments. Examples: @@ -102,12 +102,11 @@ def __init__( if token_credential is None: if _is_fabric_runtime(): - token_credential = _generate_fabric_credential() + token_credential = validate_token_credential(_generate_fabric_credential()) + logger.debug("Running in Fabric runtime - using generated Fabric credential for authentication.") else: - # if credential is not defined, use DefaultAzureCredential - from azure.identity import DefaultAzureCredential - - token_credential = DefaultAzureCredential() + msg = "A TokenCredential is required to authenticate API requests. Please pass a 'token_credential' (e.g., AzureCliCredential, ClientSecretCredential)." + raise InputError(msg, logger) else: token_credential = validate_token_credential(token_credential) diff --git a/tests/test_fabric_workspace.py b/tests/test_fabric_workspace.py index 83ccdf46..f0c13335 100644 --- a/tests/test_fabric_workspace.py +++ b/tests/test_fabric_workspace.py @@ -9,6 +9,7 @@ import pytest import yaml +from fixtures.credentials import DummyTokenCredential from fabric_cicd.fabric_workspace import FabricWorkspace, constants @@ -120,6 +121,7 @@ def _create_workspace(workspace_id, repository_directory, item_type_in_scope=Non workspace_id=workspace_id, repository_directory=repository_directory, item_type_in_scope=item_type_in_scope, + token_credential=DummyTokenCredential(), **kwargs, ) # Call refresh methods to populate workspace data @@ -1048,6 +1050,32 @@ def test_parameter_file_path_invalid_type_rejected(temp_workspace_dir, patched_f assert not workspace.environment_parameter +def test_no_token_credential_outside_fabric_runtime_raises_error(temp_workspace_dir, valid_workspace_id): + """Test that constructing FabricWorkspace without token_credential outside Fabric runtime raises InputError.""" + from fabric_cicd._common._exceptions import InputError + + # Create a simple platform file so directory validation passes + notebook_dir = temp_workspace_dir / "Test Notebook" + notebook_dir.mkdir() + platform_file = notebook_dir / ".platform" + platform_content = { + "metadata": {"type": "Notebook", "displayName": "Test Notebook"}, + "config": {"logicalId": "12345678-1234-5678-abcd-1234567890ab"}, + } + with platform_file.open("w", encoding="utf-8") as f: + json.dump(platform_content, f) + + with patch("fabric_cicd.fabric_workspace._is_fabric_runtime", return_value=False): + with pytest.raises(InputError) as exc_info: + FabricWorkspace( + workspace_id=valid_workspace_id, + repository_directory=str(temp_workspace_dir), + ) + + assert "TokenCredential is required" in str(exc_info.value) + assert "token_credential" in str(exc_info.value) + + def test_base_api_url_kwarg_raises_error(temp_workspace_dir, valid_workspace_id): """Test that passing base_api_url as kwarg raises an error.""" from fabric_cicd._common._exceptions import InputError @@ -1072,6 +1100,7 @@ def test_base_api_url_kwarg_raises_error(temp_workspace_dir, valid_workspace_id) workspace_id=valid_workspace_id, repository_directory=str(temp_workspace_dir), base_api_url="https://custom.api.url", + token_credential=DummyTokenCredential(), ) # Verify the error message contains the expected text @@ -1619,6 +1648,7 @@ def test_mix_of_default_and_non_default_logical_ids(temp_workspace_dir, patched_ assert workspace.repository_items["Notebook"]["Git Notebook"].logical_id == unique_logical_id assert workspace.repository_items["DataPipeline"]["Exported Pipeline"].logical_id == constants.DEFAULT_GUID + def test_publish_variable_library_only_calls_replace_parameters( temp_workspace_dir, patched_fabric_workspace, valid_workspace_id ): diff --git a/tests/test_publish.py b/tests/test_publish.py index 3fb65476..4c722a9f 100644 --- a/tests/test_publish.py +++ b/tests/test_publish.py @@ -11,6 +11,7 @@ from unittest.mock import MagicMock, patch import pytest +from fixtures.credentials import DummyTokenCredential import fabric_cicd.publish as publish from fabric_cicd import constants @@ -124,6 +125,7 @@ def test_publish_only_existing_item_types(mock_endpoint, temp_workspace_dir): workspace = FabricWorkspace( workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), + token_credential=DummyTokenCredential(), ) publish.publish_all_items(workspace) @@ -148,6 +150,7 @@ def test_default_none_item_type_in_scope_includes_all_types(mock_endpoint, temp_ workspace = FabricWorkspace( workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), + token_credential=DummyTokenCredential(), ) expected_types = list(constants.ACCEPTED_ITEM_TYPES) @@ -161,6 +164,7 @@ def test_empty_item_type_in_scope_list(mock_endpoint, temp_workspace_dir): workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=[], + token_credential=DummyTokenCredential(), ) assert workspace.item_type_in_scope == [] @@ -180,6 +184,7 @@ def test_invalid_item_types_in_scope(mock_endpoint, temp_workspace_dir): workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["InvalidItemType"], + token_credential=DummyTokenCredential(), ) @@ -193,6 +198,7 @@ def test_multiple_invalid_item_types_in_scope(mock_endpoint, temp_workspace_dir) workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["FakeType", "AnotherInvalidType"], + token_credential=DummyTokenCredential(), ) @@ -206,6 +212,7 @@ def test_mixed_valid_and_invalid_item_types_in_scope(mock_endpoint, temp_workspa workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["Notebook", "BadType", "Environment"], + token_credential=DummyTokenCredential(), ) @@ -245,6 +252,7 @@ def test_unpublish_feature_flag_warnings(mock_endpoint, temp_workspace_dir, capl workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["Lakehouse", "Warehouse", "SQLDatabase", "Eventhouse"], + token_credential=DummyTokenCredential(), ) publish.unpublish_all_orphan_items(workspace) @@ -288,6 +296,7 @@ def test_unpublish_with_feature_flags_enabled(mock_endpoint, temp_workspace_dir, workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["Lakehouse"], + token_credential=DummyTokenCredential(), ) publish.unpublish_all_orphan_items(workspace) @@ -339,6 +348,7 @@ def track_unpublish(_self, item_name, item_type): workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["Notebook"], + token_credential=DummyTokenCredential(), ) publish.unpublish_all_orphan_items(workspace) @@ -387,6 +397,7 @@ def track_unpublish(_self, item_name, item_type): workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["Notebook"], + token_credential=DummyTokenCredential(), ) publish.unpublish_all_orphan_items(workspace, item_name_exclude_regex=r"^Protected.*") @@ -436,6 +447,7 @@ def track_unpublish(_self, item_name, item_type): workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["Notebook"], + token_credential=DummyTokenCredential(), ) publish.unpublish_all_orphan_items(workspace, items_to_include=["TargetOrphan.Notebook"]) @@ -477,6 +489,7 @@ def track_unpublish(_self, item_name, item_type): workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["Notebook"], + token_credential=DummyTokenCredential(), ) publish.unpublish_all_orphan_items(workspace) @@ -520,6 +533,7 @@ def mock_publish_mirroreddatabase(): workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["Lakehouse", "MirroredDatabase"], + token_credential=DummyTokenCredential(), ) publish.publish_all_items(workspace) @@ -559,6 +573,7 @@ def test_folder_exclusion_with_regex(mock_endpoint, temp_workspace_dir): workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["Notebook", "SemanticModel"], + token_credential=DummyTokenCredential(), ) exclude_regex = r".*legacy.*" @@ -593,6 +608,7 @@ def test_folder_exclusion_with_anchored_regex(mock_endpoint, temp_workspace_dir) workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["Notebook"], + token_credential=DummyTokenCredential(), ) exclude_regex = r"^/legacy$" @@ -619,6 +635,7 @@ def test_item_name_exclusion_still_works(mock_endpoint, temp_workspace_dir): workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["Notebook"], + token_credential=DummyTokenCredential(), ) exclude_regex = r".*DoNotPublish.*" @@ -656,6 +673,7 @@ def test_folder_inclusion_with_folder_path_to_include(mock_endpoint, temp_worksp workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["Notebook", "SemanticModel"], + token_credential=DummyTokenCredential(), ) publish.publish_all_items( @@ -692,6 +710,7 @@ def test_folder_inclusion_and_exclusion_together(mock_endpoint, temp_workspace_d workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["Notebook"], + token_credential=DummyTokenCredential(), ) with pytest.raises( @@ -719,6 +738,7 @@ def test_empty_folder_path_to_include_raises_error(mock_endpoint, temp_workspace workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["Notebook"], + token_credential=DummyTokenCredential(), ) with pytest.raises(InputError, match="folder_path_to_include must not be an empty list"): @@ -748,6 +768,7 @@ def test_folder_exclusion_with_items_to_include(mock_endpoint, temp_workspace_di workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["Notebook"], + token_credential=DummyTokenCredential(), ) publish.publish_all_items( @@ -778,6 +799,7 @@ def test_folder_inclusion_with_item_exclusion(mock_endpoint, temp_workspace_dir) workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["Notebook"], + token_credential=DummyTokenCredential(), ) publish.publish_all_items( @@ -808,6 +830,7 @@ def test_folder_inclusion_with_items_to_include(mock_endpoint, temp_workspace_di workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["Notebook"], + token_credential=DummyTokenCredential(), ) publish.publish_all_items( @@ -840,6 +863,7 @@ def test_all_filters_combined(mock_endpoint, temp_workspace_dir): workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_workspace_dir), item_type_in_scope=["Notebook"], + token_credential=DummyTokenCredential(), ) publish.publish_all_items( diff --git a/tests/test_response_collection.py b/tests/test_response_collection.py index eea860c1..ec8fd01b 100644 --- a/tests/test_response_collection.py +++ b/tests/test_response_collection.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch import pytest +from fixtures.credentials import DummyTokenCredential import fabric_cicd.constants as constants import fabric_cicd.publish as publish @@ -91,6 +92,7 @@ def test_workspace_with_notebook(mock_endpoint): workspace_id="12345678-1234-5678-abcd-1234567890ab", repository_directory=str(temp_path), item_type_in_scope=["Notebook"], + token_credential=DummyTokenCredential(), ) # Manually set up repository items since we're patching the refresh methods workspace.repository_items = { diff --git a/tests/test_subfolders.py b/tests/test_subfolders.py index afaab109..c1077ecc 100644 --- a/tests/test_subfolders.py +++ b/tests/test_subfolders.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch import pytest +from fixtures.credentials import DummyTokenCredential from fabric_cicd.fabric_workspace import FabricWorkspace @@ -110,6 +111,7 @@ def _create_workspace(workspace_id, repository_directory, item_type_in_scope, ** workspace_id=workspace_id, repository_directory=repository_directory, item_type_in_scope=item_type_in_scope, + token_credential=DummyTokenCredential(), **kwargs, ) @@ -268,6 +270,7 @@ def mock_invoke_side_effect(method, url, **_kwargs): workspace_id=valid_workspace_id, repository_directory=str(repository_with_subfolders), item_type_in_scope=["Notebook", "DataPipeline"], + token_credential=DummyTokenCredential(), ) # Call methods in the intended order to populate folder structures