diff --git a/airflow-ctl-tests/tests/airflowctl_tests/conftest.py b/airflow-ctl-tests/tests/airflowctl_tests/conftest.py index 3ac692722f212..9e6ef75b7fa5a 100644 --- a/airflow-ctl-tests/tests/airflowctl_tests/conftest.py +++ b/airflow-ctl-tests/tests/airflowctl_tests/conftest.py @@ -20,6 +20,7 @@ import subprocess import sys +import pytest from python_on_whales import DockerClient, docker from airflowctl_tests import console @@ -27,11 +28,85 @@ AIRFLOW_ROOT_PATH, DOCKER_COMPOSE_FILE_PATH, DOCKER_IMAGE, + LOGIN_COMMAND, + LOGIN_OUTPUT, ) from tests_common.test_utils.fernet import generate_fernet_key_string +@pytest.fixture +def run_command(): + """Fixture that provides a helper to run airflowctl commands.""" + + def _run_command(command: str, skip_login: bool = False) -> str: + import os + from subprocess import PIPE, STDOUT, Popen + + host_envs = os.environ.copy() + host_envs["AIRFLOW_CLI_DEBUG_MODE"] = "true" + + command_from_config = f"airflowctl {command}" + + # We need to run auth login first for all commands except login itself (unless skipped) + if not skip_login and command != LOGIN_COMMAND: + run_cmd = f"airflowctl {LOGIN_COMMAND} && {command_from_config}" + else: + run_cmd = command_from_config + + console.print(f"[yellow]Running command: {command}") + + # Give some time for the command to execute and output to be ready + proc = Popen(run_cmd.encode(), stdout=PIPE, stderr=STDOUT, stdin=PIPE, shell=True, env=host_envs) + stdout_bytes, stderr_result = proc.communicate(timeout=60) + + # CLI command gave errors + assert not stderr_result, ( + f"Errors while executing command '{command_from_config}':\n{stderr_result.decode()}" + ) + + # Decode the output + stdout_result = stdout_bytes.decode() + + # We need to trim auth login output if the command is not login itself and clean backspaces + if not skip_login and command != LOGIN_COMMAND: + assert LOGIN_OUTPUT in stdout_result, ( + f"❌ Login output not found before command output for '{command_from_config}'\nFull output:\n{stdout_result}" + ) + stdout_result = stdout_result.split(f"{LOGIN_OUTPUT}\n")[1].strip() + else: + stdout_result = stdout_result.strip() + + # Check for non-zero exit code + assert proc.returncode == 0, ( + f"❌ Command '{command_from_config}' exited with code {proc.returncode}\nOutput:\n{stdout_result}" + ) + + # Error patterns to detect failures that might otherwise slip through + # Please ensure it is aligning with airflowctl.api.client.get_json_error + error_patterns = [ + "Server error", + "command error", + "unrecognized arguments", + "invalid choice", + "Traceback (most recent call last):", + ] + matched_error = next((error for error in error_patterns if error in stdout_result), None) + assert not matched_error, ( + f"❌ Output contained unexpected text for command '{command_from_config}'\n" + f"Matched error pattern: {matched_error}\n" + f"Output:\n{stdout_result}" + ) + + console.print(f"[green]✅ Output did not contain unexpected text for command '{command_from_config}'") + console.print(f"[cyan]Result:\n{stdout_result}\n") + proc.kill() + + return stdout_result + + return _run_command + + class _CtlTestState: docker_client: DockerClient | None = None diff --git a/airflow-ctl-tests/tests/airflowctl_tests/constants.py b/airflow-ctl-tests/tests/airflowctl_tests/constants.py index 549a9ad8c7439..4ddf2636c7f81 100644 --- a/airflow-ctl-tests/tests/airflowctl_tests/constants.py +++ b/airflow-ctl-tests/tests/airflowctl_tests/constants.py @@ -30,3 +30,6 @@ DOCKER_COMPOSE_FILE_PATH = ( AIRFLOW_ROOT_PATH / "airflow-core" / "docs" / "howto" / "docker-compose" / "docker-compose.yaml" ) + +LOGIN_COMMAND = "auth login --username airflow --password airflow" +LOGIN_OUTPUT = "Login successful! Welcome to airflowctl!" diff --git a/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py b/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py index ceb29f0143de6..228321ac7e89d 100644 --- a/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py +++ b/airflow-ctl-tests/tests/airflowctl_tests/test_airflowctl_commands.py @@ -16,12 +16,9 @@ # under the License. from __future__ import annotations -import os -from subprocess import PIPE, STDOUT, Popen - import pytest -from airflowctl_tests import console +from airflowctl_tests.constants import LOGIN_COMMAND def date_param(): @@ -47,8 +44,6 @@ def date_param(): return random_dt.isoformat() -LOGIN_COMMAND = "auth login --username airflow --password airflow" -LOGIN_OUTPUT = "Login successful! Welcome to airflowctl!" ONE_DATE_PARAM = date_param() TEST_COMMANDS = [ # Passing password via command line is insecure but acceptable for testing purposes @@ -134,62 +129,6 @@ def date_param(): @pytest.mark.parametrize( "command", TEST_COMMANDS, ids=[" ".join(command.split(" ", 2)[:2]) for command in TEST_COMMANDS] ) -def test_airflowctl_commands(command: str): +def test_airflowctl_commands(command: str, run_command): """Test airflowctl commands using docker-compose environment.""" - host_envs = os.environ.copy() - host_envs["AIRFLOW_CLI_DEBUG_MODE"] = "true" - - command_from_config = f"airflowctl {command}" - # We need to run auth login first for all commands except login itself - if command != LOGIN_COMMAND: - run_command = f"airflowctl {LOGIN_COMMAND} && {command_from_config}" - else: - run_command = command_from_config - console.print(f"[yellow]Running command: {command}") - - # Give some time for the command to execute and output to be ready - proc = Popen(run_command.encode(), stdout=PIPE, stderr=STDOUT, stdin=PIPE, shell=True, env=host_envs) - stdout_bytes, stderr_result = proc.communicate(timeout=60) - - # CLI command gave errors - assert not stderr_result, ( - f"Errors while executing command '{command_from_config}':\n{stderr_result.decode()}" - ) - - # Decode the output - stdout_result = stdout_bytes.decode() - # We need to trim auth login output if the command is not login itself and clean backspaces - if command != LOGIN_COMMAND: - assert LOGIN_OUTPUT in stdout_result, ( - f"❌ Login output not found before command output for '{command_from_config}'", - f"\nFull output:\n{stdout_result}", - ) - stdout_result = stdout_result.split(f"{LOGIN_OUTPUT}\n")[1].strip() - else: - stdout_result = stdout_result.strip() - - # Check for non-zero exit code - assert proc.returncode == 0, ( - f"❌ Command '{command_from_config}' exited with code {proc.returncode}", - f"\nOutput:\n{stdout_result}", - ) - - # Error patterns to detect failures that might otherwise slip through - # Please ensure it is aligning with airflowctl.api.client.get_json_error - error_patterns = [ - "Server error", - "command error", - "unrecognized arguments", - "invalid choice", - "Traceback (most recent call last):", - ] - matched_error = next((error for error in error_patterns if error in stdout_result), None) - assert not matched_error, ( - f"❌ Output contained unexpected text for command '{command_from_config}'", - f"\nMatched error pattern: {matched_error}", - f"\nOutput:\n{stdout_result}", - ) - - console.print(f"[green]✅ Output did not contain unexpected text for command '{command_from_config}'") - console.print(f"[cyan]Result:\n{stdout_result}\n") - proc.kill() + run_command(command) diff --git a/airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.py b/airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.py new file mode 100644 index 0000000000000..84719bb4cc3cf --- /dev/null +++ b/airflow-ctl-tests/tests/airflowctl_tests/test_config_sensitive_masking.py @@ -0,0 +1,51 @@ +# 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 + +# Test commands for config sensitive masking verification +SENSITIVE_CONFIG_COMMANDS = [ + # Test that config list shows masked sensitive values + "config list", + # Test that getting specific sensitive config values are masked + "config get --section core --option fernet_key", + "config get --section database --option sql_alchemy_conn", +] + + +@pytest.mark.parametrize( + "command", + SENSITIVE_CONFIG_COMMANDS, + ids=[" ".join(command.split(" ", 2)[:2]) for command in SENSITIVE_CONFIG_COMMANDS], +) +def test_config_sensitive_masking(command: str, run_command): + """ + Test that sensitive config values are properly masked by airflowctl. + + This integration test verifies that when airflowctl retrieves config data from the + Airflow API, sensitive values (like fernet_key, sql_alchemy_conn) appear masked + as '< hidden >' and do not leak actual secret values. + """ + stdout_result = run_command(command) + + # CRITICAL: Verify that sensitive values are masked + # The Airflow API returns masked values as "< hidden >" for sensitive configs + assert "< hidden >" in stdout_result, ( + f"❌ Expected masked value '< hidden >' not found in output for 'airflowctl {command}'\n" + f"Output:\n{stdout_result}" + ) diff --git a/dev/breeze/src/airflow_breeze/utils/run_tests.py b/dev/breeze/src/airflow_breeze/utils/run_tests.py index 9dff5d3935b91..211384eeafee1 100644 --- a/dev/breeze/src/airflow_breeze/utils/run_tests.py +++ b/dev/breeze/src/airflow_breeze/utils/run_tests.py @@ -152,7 +152,7 @@ def run_docker_compose_tests( test_path = Path("tests") / "airflow_e2e_tests" / f"{test_mode}_tests" cwd = AIRFLOW_E2E_TESTS_ROOT_PATH.as_posix() elif test_type == "airflow-ctl-integration": - test_path = Path("tests") / "airflowctl_tests" / "test_airflowctl_commands.py" + test_path = Path("tests") / "airflowctl_tests" cwd = AIRFLOW_CTL_TESTS_ROOT_PATH.as_posix() else: test_path = Path("tests") / "docker_tests" / "test_docker_compose_quick_start.py"