blob: 5c9cb7a22f539e1498f91fe248e432a9345418ad [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from __future__ import annotations
import pytest
from airflow.sdk.api.datamodels._generated import ConnectionResponse
from airflow.sdk.exceptions import AirflowSecretsBackendAccessDenied, ErrorType
from airflow.sdk.execution_time.comms import ConnectionResult, ErrorResponse, VariableResult
from airflow.sdk.execution_time.secrets.execution_api import ExecutionAPISecretsBackend
class TestExecutionAPISecretsBackend:
"""Test ExecutionAPISecretsBackend."""
def test_get_connection_via_supervisor_comms(self, mock_supervisor_comms):
"""Test that connection is retrieved via SUPERVISOR_COMMS."""
conn_response = ConnectionResponse(
conn_id="test_conn",
conn_type="http",
host="example.com",
port=443,
schema="https",
)
conn_result = ConnectionResult.from_conn_response(conn_response)
mock_supervisor_comms.send.return_value = conn_result
backend = ExecutionAPISecretsBackend()
conn = backend.get_connection("test_conn")
assert conn is not None
assert conn.conn_id == "test_conn"
assert conn.conn_type == "http"
assert conn.host == "example.com"
mock_supervisor_comms.send.assert_called_once()
def test_get_connection_not_found(self, mock_supervisor_comms):
"""Test that None is returned when connection not found."""
error_response = ErrorResponse(error=ErrorType.CONNECTION_NOT_FOUND, detail={"message": "Not found"})
mock_supervisor_comms.send.return_value = error_response
backend = ExecutionAPISecretsBackend()
conn = backend.get_connection("nonexistent")
assert conn is None
mock_supervisor_comms.send.assert_called_once()
def test_get_variable_via_supervisor_comms(self, mock_supervisor_comms):
"""Test that variable is retrieved via SUPERVISOR_COMMS."""
var_result = VariableResult(key="test_var", value="test_value")
mock_supervisor_comms.send.return_value = var_result
backend = ExecutionAPISecretsBackend()
value = backend.get_variable("test_var")
assert value == "test_value"
mock_supervisor_comms.send.assert_called_once()
def test_get_variable_not_found(self, mock_supervisor_comms):
"""Test that None is returned when variable not found."""
error_response = ErrorResponse(error=ErrorType.VARIABLE_NOT_FOUND, detail={"message": "Not found"})
mock_supervisor_comms.send.return_value = error_response
backend = ExecutionAPISecretsBackend()
value = backend.get_variable("nonexistent")
assert value is None
mock_supervisor_comms.send.assert_called_once()
def test_get_connection_handles_exception(self, mock_supervisor_comms):
"""Test that exceptions are handled gracefully."""
mock_supervisor_comms.send.side_effect = RuntimeError("Connection failed")
backend = ExecutionAPISecretsBackend()
conn = backend.get_connection("test_conn")
# Should return None on exception to allow fallback to other backends
assert conn is None
def test_get_variable_handles_exception(self, mock_supervisor_comms):
"""Test that exceptions are handled gracefully for variables."""
mock_supervisor_comms.send.side_effect = RuntimeError("Communication failed")
backend = ExecutionAPISecretsBackend()
value = backend.get_variable("test_var")
# Should return None on exception to allow fallback to other backends
assert value is None
def test_get_conn_value_not_implemented(self):
"""Test that get_conn_value raises NotImplementedError."""
backend = ExecutionAPISecretsBackend()
with pytest.raises(NotImplementedError, match="Use get_connection instead"):
backend.get_conn_value("test_conn")
def test_get_connection_raises_on_permission_denied(self, mock_supervisor_comms):
"""An explicit deny from the Execution API must raise, not fall through.
Returning None on a 401/403 would let the secrets-backend dispatcher
fall through to a less-restrictive backend (e.g. EnvironmentVariablesBackend).
"""
mock_supervisor_comms.send.return_value = ErrorResponse(
error=ErrorType.PERMISSION_DENIED,
detail={"conn_id": "denied_conn", "status_code": 403},
)
backend = ExecutionAPISecretsBackend()
with pytest.raises(AirflowSecretsBackendAccessDenied, match="connection 'denied_conn'"):
backend.get_connection("denied_conn")
def test_get_variable_raises_on_permission_denied(self, mock_supervisor_comms):
"""An explicit deny from the Execution API must raise for variables too."""
mock_supervisor_comms.send.return_value = ErrorResponse(
error=ErrorType.PERMISSION_DENIED,
detail={"key": "denied_var", "status_code": 403},
)
backend = ExecutionAPISecretsBackend()
with pytest.raises(AirflowSecretsBackendAccessDenied, match="variable 'denied_var'"):
backend.get_variable("denied_var")
@pytest.mark.asyncio
async def test_aget_connection_raises_on_permission_denied(self, mock_supervisor_comms):
"""Async variant must also raise on PERMISSION_DENIED."""
async def asend(*_args, **_kwargs):
return ErrorResponse(
error=ErrorType.PERMISSION_DENIED,
detail={"conn_id": "denied_conn", "status_code": 403},
)
mock_supervisor_comms.asend = asend
backend = ExecutionAPISecretsBackend()
with pytest.raises(AirflowSecretsBackendAccessDenied, match="connection 'denied_conn'"):
await backend.aget_connection("denied_conn")
@pytest.mark.asyncio
async def test_aget_variable_raises_on_permission_denied(self, mock_supervisor_comms):
"""Async variant for variables must also raise on PERMISSION_DENIED."""
async def asend(*_args, **_kwargs):
return ErrorResponse(
error=ErrorType.PERMISSION_DENIED,
detail={"key": "denied_var", "status_code": 403},
)
mock_supervisor_comms.asend = asend
backend = ExecutionAPISecretsBackend()
with pytest.raises(AirflowSecretsBackendAccessDenied, match="variable 'denied_var'"):
await backend.aget_variable("denied_var")
class TestDispatcherRefusesFallbackOnDeny:
"""End-to-end: the secrets-backend dispatcher must NOT fall through on an authoritative deny.
A backend-level raise is not enough on its own — the outer ``except Exception:`` in
``context._get_connection`` / ``_get_variable`` / ``_async_get_connection`` previously
swallowed ``PermissionError`` and silently called the next (less-restrictive) backend.
These tests pin the dispatcher behaviour by inserting a spy backend AFTER
``ExecutionAPISecretsBackend`` and asserting it is never called once the first backend
raises ``AirflowSecretsBackendAccessDenied``.
"""
def test_get_connection_does_not_fall_through_after_deny(self, mock_supervisor_comms, monkeypatch):
from unittest.mock import MagicMock
from airflow.sdk.execution_time import context as ctx_module
mock_supervisor_comms.send.return_value = ErrorResponse(
error=ErrorType.PERMISSION_DENIED,
detail={"conn_id": "denied_conn", "status_code": 403},
)
later_backend = MagicMock(name="LaterBackend")
later_backend.get_connection.return_value = MagicMock(name="leaked_conn")
monkeypatch.setattr(
"airflow.sdk.execution_time.supervisor.ensure_secrets_backend_loaded",
lambda: [ExecutionAPISecretsBackend(), later_backend],
)
with pytest.raises(AirflowSecretsBackendAccessDenied, match="connection 'denied_conn'"):
ctx_module._get_connection("denied_conn")
later_backend.get_connection.assert_not_called()
def test_get_variable_does_not_fall_through_after_deny(self, mock_supervisor_comms, monkeypatch):
from unittest.mock import MagicMock
from airflow.sdk.execution_time import context as ctx_module
mock_supervisor_comms.send.return_value = ErrorResponse(
error=ErrorType.PERMISSION_DENIED,
detail={"key": "denied_var", "status_code": 403},
)
later_backend = MagicMock(name="LaterBackend")
later_backend.get_variable.return_value = "leaked-value"
monkeypatch.setattr(
"airflow.sdk.execution_time.supervisor.ensure_secrets_backend_loaded",
lambda: [ExecutionAPISecretsBackend(), later_backend],
)
with pytest.raises(AirflowSecretsBackendAccessDenied, match="variable 'denied_var'"):
ctx_module._get_variable("denied_var", deserialize_json=False)
later_backend.get_variable.assert_not_called()
@pytest.mark.asyncio
async def test_async_get_connection_does_not_fall_through_after_deny(
self, mock_supervisor_comms, monkeypatch
):
from unittest.mock import MagicMock
from airflow.sdk.execution_time import context as ctx_module
async def asend(*_args, **_kwargs):
return ErrorResponse(
error=ErrorType.PERMISSION_DENIED,
detail={"conn_id": "denied_conn", "status_code": 403},
)
mock_supervisor_comms.asend = asend
later_backend = MagicMock(name="LaterBackend")
# The dispatcher prefers aget_connection if present; mock both for safety.
later_backend.aget_connection = MagicMock(return_value=MagicMock(name="leaked_conn"))
later_backend.get_connection = MagicMock(return_value=MagicMock(name="leaked_conn"))
monkeypatch.setattr(
"airflow.sdk.execution_time.supervisor.ensure_secrets_backend_loaded",
lambda: [ExecutionAPISecretsBackend(), later_backend],
)
with pytest.raises(AirflowSecretsBackendAccessDenied, match="connection 'denied_conn'"):
await ctx_module._async_get_connection("denied_conn")
later_backend.aget_connection.assert_not_called()
later_backend.get_connection.assert_not_called()
class TestContextDetection:
"""Test context detection in ensure_secrets_backend_loaded."""
def test_client_context_with_supervisor_comms(self, mock_supervisor_comms):
"""Client context: SUPERVISOR_COMMS set → uses worker chain."""
from airflow.sdk.execution_time.supervisor import ensure_secrets_backend_loaded
backends = ensure_secrets_backend_loaded()
backend_classes = [type(b).__name__ for b in backends]
assert "ExecutionAPISecretsBackend" in backend_classes
assert "MetastoreBackend" not in backend_classes
def test_server_context_with_env_var(self, monkeypatch):
"""Server context: env var set → uses server chain."""
import sys
from airflow.sdk.execution_time.supervisor import ensure_secrets_backend_loaded
monkeypatch.setenv("_AIRFLOW_PROCESS_CONTEXT", "server")
# Ensure SUPERVISOR_COMMS is not available
if "airflow.sdk.execution_time.task_runner" in sys.modules:
monkeypatch.delitem(sys.modules, "airflow.sdk.execution_time.task_runner")
backends = ensure_secrets_backend_loaded()
backend_classes = [type(b).__name__ for b in backends]
assert "MetastoreBackend" in backend_classes
assert "ExecutionAPISecretsBackend" not in backend_classes
def test_fallback_context_no_markers(self, monkeypatch):
"""Fallback context: no SUPERVISOR_COMMS, no env var → only env vars + external."""
import sys
from airflow.sdk.execution_time.supervisor import ensure_secrets_backend_loaded
# Ensure no SUPERVISOR_COMMS
if "airflow.sdk.execution_time.task_runner" in sys.modules:
monkeypatch.delitem(sys.modules, "airflow.sdk.execution_time.task_runner")
# Ensure no env var
monkeypatch.delenv("_AIRFLOW_PROCESS_CONTEXT", raising=False)
backends = ensure_secrets_backend_loaded()
backend_classes = [type(b).__name__ for b in backends]
assert "EnvironmentVariablesBackend" in backend_classes
assert "MetastoreBackend" not in backend_classes
assert "ExecutionAPISecretsBackend" not in backend_classes