Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions airflow-ctl-tests/tests/airflowctl_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,93 @@
import subprocess
import sys

import pytest
from python_on_whales import DockerClient, docker

from airflowctl_tests import console
from airflowctl_tests.constants import (
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

Expand Down
3 changes: 3 additions & 0 deletions airflow-ctl-tests/tests/airflowctl_tests/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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!"
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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
Expand Down Expand Up @@ -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)
Original file line number Diff line number Diff line change
@@ -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}"
)
2 changes: 1 addition & 1 deletion dev/breeze/src/airflow_breeze/utils/run_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading