diff --git a/src/keboola_agent_cli/services/component_service.py b/src/keboola_agent_cli/services/component_service.py index 0275e473..6e3d9d09 100644 --- a/src/keboola_agent_cli/services/component_service.py +++ b/src/keboola_agent_cli/services/component_service.py @@ -488,6 +488,15 @@ def run_sync_action( ``storage`` key REPLACES the root key wholesale (never deep-merged), so e.g. a row ``storage.input`` replaces the root ``storage.input``. + ``runtime`` and ``authorization`` are taken from the ROOT configuration + only (rows never override them, per the docker-runner contract) and are + forwarded only when non-empty. ``authorization.oauth_api.id`` is a + broker reference the sync-actions service resolves and decrypts before + invoking the component; omitting it made OAuth/Service-Account + components (e.g. ``keboola.ex-linkedin-ads``) crash before their own + error handling could run, surfacing as an opaque empty-body 400 + (AI-3757 / SUPPORT-17393). + Args: alias: Project alias (resolves stack URL + token). component_id: Component identifier (e.g. 'keboola.ex-db-mysql'). @@ -531,7 +540,7 @@ def run_sync_action( row_configuration = row.get("configuration") or {} # SHALLOW top-level merge (MCP parity): row keys replace root # keys wholesale; do NOT deep-merge. - config_data = { + config_data: dict[str, Any] = { "parameters": { **root_configuration.get("parameters", {}), **row_configuration.get("parameters", {}), @@ -541,6 +550,12 @@ def run_sync_action( **row_configuration.get("storage", {}), }, } + runtime = root_configuration.get("runtime") or {} + authorization = root_configuration.get("authorization") or {} + if runtime: + config_data["runtime"] = runtime + if authorization: + config_data["authorization"] = authorization result = client.run_sync_action( component_id, action, diff --git a/tests/test_component_sync_action.py b/tests/test_component_sync_action.py index 5651656a..1dc48ae2 100644 --- a/tests/test_component_sync_action.py +++ b/tests/test_component_sync_action.py @@ -125,14 +125,21 @@ def test_run_sync_action_api_error(self, httpx_mock) -> None: def _root_config_response( parameters: dict[str, Any] | None = None, storage: dict[str, Any] | None = None, + runtime: dict[str, Any] | None = None, + authorization: dict[str, Any] | None = None, ) -> dict[str, Any]: + configuration: dict[str, Any] = { + "parameters": parameters if parameters is not None else {}, + "storage": storage if storage is not None else {}, + } + if runtime is not None: + configuration["runtime"] = runtime + if authorization is not None: + configuration["authorization"] = authorization return { "id": CONFIG_ID, "name": "MySQL extractor", - "configuration": { - "parameters": parameters if parameters is not None else {}, - "storage": storage if storage is not None else {}, - }, + "configuration": configuration, } @@ -259,6 +266,57 @@ def test_root_row_shallow_merge_not_deep(self, tmp_config_dir: Path) -> None: # storage merged independently with the same semantics: assert sent_config_data["storage"] == {"input": {"tables": [{"source": "in.c-main.row"}]}} + def test_authorization_and_runtime_forwarded_from_root(self, tmp_config_dir: Path) -> None: + """OAuth/Service-Account components need root authorization+runtime forwarded (AI-3757). + + Without this, e.g. keboola.ex-linkedin-ads never receives its OAuth + broker reference and crashes before its own error handling runs, + surfacing as an opaque empty-body 400. + """ + client = MagicMock() + client.get_config_detail.return_value = _root_config_response( + parameters={"ad_account_id": "123"}, + runtime={"parallelism": "5"}, + authorization={"oauth_api": {"id": "linkedin-ads"}}, + ) + client.get_config_row.return_value = _row_config_response( + parameters={"ad_account_id": "456"} + ) + client.run_sync_action.return_value = {"accounts": []} + service = _make_service(tmp_config_dir, client) + + service.run_sync_action( + alias="prod", + component_id="keboola.ex-linkedin-ads", + action="list_accounts", + config_id=CONFIG_ID, + row_id=ROW_ID, + ) + + sent_config_data = client.run_sync_action.call_args.args[2] + assert sent_config_data["runtime"] == {"parallelism": "5"} + assert sent_config_data["authorization"] == {"oauth_api": {"id": "linkedin-ads"}} + + def test_authorization_and_runtime_omitted_when_absent(self, tmp_config_dir: Path) -> None: + """No authorization/runtime key at all when the root config has none.""" + client = MagicMock() + client.get_config_detail.return_value = _root_config_response( + parameters={"host": "example.com"} + ) + client.run_sync_action.return_value = {"status": "success"} + service = _make_service(tmp_config_dir, client) + + service.run_sync_action( + alias="prod", + component_id=COMPONENT_ID, + action="testConnection", + config_id=CONFIG_ID, + ) + + sent_config_data = client.run_sync_action.call_args.args[2] + assert "runtime" not in sent_config_data + assert "authorization" not in sent_config_data + def test_branch_pass_through(self, tmp_config_dir: Path) -> None: """branch_id flows to config fetch, row fetch, and the action call.""" client = MagicMock()