diff --git a/dev-docs/PRD_AND_ARCHITECTURE.md b/dev-docs/PRD_AND_ARCHITECTURE.md index f2495872..cad3b960 100644 --- a/dev-docs/PRD_AND_ARCHITECTURE.md +++ b/dev-docs/PRD_AND_ARCHITECTURE.md @@ -5727,21 +5727,32 @@ apic: ### Controller Type Auto-Detection -**Location:** `nac_test/utils/controller.py` +**Location:** `nac_test/core/controller.py` nac-test automatically detects the network architecture based on which credential environment variables are set, eliminating the need for users to explicitly set `CONTROLLER_TYPE`. +Controller resolution follows a **single source of truth (SSOT)** pattern. +All controller metadata — env var names, credential sets, auth methods, +display names, defaults prefixes, and insecure-flag env vars — lives in +`CONTROLLER_REGISTRY` in `nac_test/core/controller.py`. + #### CONTROLLER_REGISTRY and CredentialSets ```python from dataclasses import dataclass +from collections.abc import Mapping @dataclass(frozen=True) class CredentialSet: """A single credential combination that can authenticate to a controller.""" - env_vars: list[str] # Env vars required for this method - label: str # Human-readable label (e.g., "API Token (20.18+)") - auth_method: str = "session" # Auth mechanism hint for downstream adapters + fields: Mapping[CredentialKind, str] # kind → env var name (SSOT) + label: str # Human-readable label + auth_method: str = "session" # Auth mechanism for downstream adapters + + @property + def env_vars(self) -> tuple[str, ...]: + """Derived from fields — cannot drift.""" + return tuple(self.fields.values()) @dataclass(frozen=True) class ControllerConfig: @@ -5792,44 +5803,85 @@ CONTROLLER_REGISTRY: dict[str, ControllerConfig] = { } ``` -#### Detection Algorithm +#### Resolution Algorithm ```python -def detect_controller_type() -> str: - """Auto-detect controller type from environment variables. +@dataclass(frozen=True) +class ControllerContext: + """Resolved controller selection identity (type + auth method). + Not connection state — credentials resolved separately via get_connection_params(). + """ + controller_type: ControllerTypeKey + auth_method: str + +def resolve_controller() -> ControllerContext: + """Single source of truth for controller detection. Iterates through CONTROLLER_REGISTRY. For each controller, iterates its credential_sets in order. The first CredentialSet whose env_vars are ALL - present and non-empty marks the controller as detected. The winning - CredentialSet is stored for later retrieval via get_matched_credential_set(). + present and non-empty marks the controller as detected. Returns: - Controller type key (e.g., "ACI", "SDWAN") + ControllerContext with controller_type and auth_method. Raises: - ValueError: If no credentials, multiple controllers, or incomplete credentials + NoCredentialsFound: No controller env vars detected. + MultipleControllersFound: More than one controller configured. + IncompleteCredentials: Some env vars present but no complete set. Example: # If SDWAN_URL and SDWAN_API_TOKEN are set: - detect_controller_type() → "SDWAN" - get_matched_credential_set("SDWAN").auth_method → "token" + ctx = resolve_controller() + ctx.controller_type → "SDWAN" + ctx.auth_method → "token" """ - complete_sets, partial_controllers, matched_creds = _find_credential_sets() - # ... validation logic (multiple, none, incomplete) ... - controller_type = complete_sets[0] - _matched_credential_sets[controller_type] = matched_creds[controller_type] - return controller_type - -def get_matched_credential_set(controller_type: str) -> CredentialSet | None: - """Public API for nac-test-pyats-common to retrieve the winning credential set. +def get_controller_context() -> ControllerContext: + """Subprocess accessor — reads NAC_TEST_CONTROLLER_CONTEXT env var. + Falls back to resolve_controller() if env var is absent (transitional). + """ - Returns the CredentialSet that was matched during detect_controller_type(). - The auth_method attribute tells the auth adapter which mechanism to use. +def get_connection_params(controller_type, auth_method) -> dict[CredentialKind, str]: + """Resolve credential values by kind from environment. + Example: {"url": "https://...", "token": "abc.def.ghi"} """ - return _matched_credential_sets.get(controller_type) + +def should_verify_ssl(controller_type, default=False) -> bool: + """Determine whether SSL certificate verification should be enabled.""" +``` + +#### Resolution Flow + +``` +CombinedOrchestrator + └─ resolve_controller() # Single detection call site + └─ Returns ControllerContext(controller_type, auth_method) + │ + ├─ preflight_auth_check(ctx) # Pre-flight validation + │ + └─ PyATSOrchestrator(controller_context=ctx) + └─ Serializes to NAC_TEST_CONTROLLER_CONTEXT at subprocess launch + │ + └─ NACTestBase.setup() + └─ get_controller_context() # Deserializes from env + └─ get_connection_params() # Reads credentials from env + └─ get_controller_url() # Reads URL from env + └─ should_verify_ssl() # Reads SSL flag from env ``` +#### Cross-Package Contract (nac-test-pyats-common) + +Auth adapters consume these functions from `nac_test.core.controller`: + +| Function | Purpose | +|----------|---------| +| `get_controller_context()` | Controller identity in subprocess | +| `get_connection_params()` | Credential values by kind | +| `should_verify_ssl()` | SSL verification toggle | + +Adapters validate `controller_type` and `auth_method` at setup time +via guards (`_SUPPORTED_AUTH_METHODS`). + #### Detection Flow Diagram ```mermaid diff --git a/nac_test/_env.py b/nac_test/_env.py index ba224479..42287195 100644 --- a/nac_test/_env.py +++ b/nac_test/_env.py @@ -3,8 +3,8 @@ """Low-level environment variable parsing utilities. -For higher-level environment validation (required vars, controller credentials), -see nac_test.utils.environment.EnvironmentValidator. +For higher-level environment validation, see nac_test.utils.environment. +For controller resolution and credentials, see nac_test.core.controller. """ # Why _env.py lives here instead of utils/env.py: @@ -98,3 +98,26 @@ def get_positive_numeric_env( ) return default + + +def is_env_var_set(var: str) -> bool: + """Check if an environment variable exists and has a non-whitespace value. + + Args: + var: Environment variable name. + + Returns: + True if the variable exists and contains non-whitespace content. + + Example: + >>> os.environ["MY_VAR"] = "value" + >>> is_env_var_set("MY_VAR") + True + >>> os.environ["EMPTY"] = " " + >>> is_env_var_set("EMPTY") + False + >>> is_env_var_set("NOT_SET") + False + """ + value = os.environ.get(var) + return bool(value and value.strip()) diff --git a/nac_test/cli/ui/banners.py b/nac_test/cli/ui/banners.py index e836224f..61f799fd 100644 --- a/nac_test/cli/ui/banners.py +++ b/nac_test/cli/ui/banners.py @@ -12,7 +12,10 @@ import typer -from nac_test.utils.controller import get_display_name +from nac_test.core.controller import ( + get_credential_vars, + get_display_name, +) from nac_test.utils.terminal import TerminalColors from nac_test.utils.url import extract_host @@ -239,11 +242,27 @@ def display_aci_defaults_banner() -> None: _render_banner(title, content_lines) +def _credential_remediation_lines(controller_type: str) -> list[str]: + """Build 'export VAR=' lines for every credential var a controller accepts. + + Derives var names and kinds from ``CONTROLLER_REGISTRY`` via the + :func:`~nac_test.core.controller.get_credential_vars` primitive, so + token-only credential sets (e.g. SDWAN's API token) are represented correctly. + """ + try: + cred_vars = get_credential_vars(controller_type) + except KeyError: + return [ + f" export {controller_type}_USERNAME=", + f" export {controller_type}_PASSWORD=", + ] + return [f" export {var}=<{kind}>" for kind, var in cred_vars] + + def display_auth_failure_banner( controller_type: str, controller_url: str, detail: str, - env_var_prefix: str, ) -> None: """Display a prominent banner when controller authentication fails. @@ -255,7 +274,6 @@ def display_auth_failure_banner( controller_type: The controller type string (e.g., "ACI", "SDWAN", "CC"). controller_url: The URL that was attempted. detail: Human-readable error detail (e.g., "HTTP 401: Unauthorized"). - env_var_prefix: The environment variable prefix (e.g., "ACI", "SDWAN", "CC"). Note: Uses the same box style and color handling as display_aci_defaults_banner. @@ -274,8 +292,7 @@ def display_auth_failure_banner( ), "", "Verify your credentials:", - f" export {env_var_prefix}_USERNAME=", - f" export {env_var_prefix}_PASSWORD=", + *_credential_remediation_lines(controller_type), "", ] _render_banner(title, content_lines) diff --git a/nac_test/cli/validators/__init__.py b/nac_test/cli/validators/__init__.py index 3ce4b6cf..711c71ec 100644 --- a/nac_test/cli/validators/__init__.py +++ b/nac_test/cli/validators/__init__.py @@ -13,13 +13,12 @@ from nac_test.cli.validators.aci_defaults import validate_aci_defaults from nac_test.cli.validators.args import validate_extra_args -from nac_test.cli.validators.common import is_architecture_active -from nac_test.cli.validators.controller_auth import ( +from nac_test.core.controller import CONTROLLER_REGISTRY, ControllerConfig +from nac_test.core.controller_auth import ( AuthCheckResult, preflight_auth_check, ) from nac_test.core.error_classification import AuthOutcome -from nac_test.utils.controller import CONTROLLER_REGISTRY, ControllerConfig from nac_test.utils.url import extract_host __all__ = [ @@ -28,7 +27,6 @@ "CONTROLLER_REGISTRY", "ControllerConfig", "extract_host", - "is_architecture_active", "validate_extra_args", "preflight_auth_check", "validate_aci_defaults", diff --git a/nac_test/cli/validators/aci_defaults.py b/nac_test/cli/validators/aci_defaults.py index 02e2172b..b9563ae5 100644 --- a/nac_test/cli/validators/aci_defaults.py +++ b/nac_test/cli/validators/aci_defaults.py @@ -11,13 +11,11 @@ import os from pathlib import Path +from nac_test.core.controller import CONTROLLER_REGISTRY from nac_test.utils.yaml import YAMLError, safe_load logger = logging.getLogger(__name__) -# Environment variable that indicates ACI environment -ACI_URL_ENV_VAR = "ACI_URL" - def validate_aci_defaults(data_paths: list[Path]) -> bool: """Validate that ACI defaults file is provided when in ACI environment. @@ -43,7 +41,7 @@ def validate_aci_defaults(data_paths: list[Path]) -> bool: True if validation passes (not ACI environment, or defaults found). False if ACI environment detected AND no defaults structure found. """ - aci_url = os.environ.get(ACI_URL_ENV_VAR) + aci_url = os.environ.get(CONTROLLER_REGISTRY["ACI"].url_env_var) if not aci_url: # Not an ACI environment, no validation needed return True diff --git a/nac_test/cli/validators/common.py b/nac_test/cli/validators/common.py deleted file mode 100644 index 78261e4d..00000000 --- a/nac_test/cli/validators/common.py +++ /dev/null @@ -1,41 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -# Copyright (c) 2025 Daniel Schmidt -"""Common utilities for architecture-specific validators. - -This module provides shared helper functions used across multiple -architecture validators (ACI, SD-WAN, Catalyst Center, etc.). -""" - -import os - - -def is_architecture_active(arch: str) -> bool: - """Check if specific architecture credentials are present in environment. - - This provides a lightweight check to determine if a particular controller - architecture is configured, without requiring full credential validation. - - Args: - arch: Architecture name in uppercase (ACI, SDWAN, CC, MERAKI, FMC, ISE). - - Returns: - True if the architecture's URL environment variable is set and non-empty. - False otherwise. - - Example: - >>> import os - >>> os.environ["ACI_URL"] = "https://apic.local" - >>> is_architecture_active("ACI") - True - >>> is_architecture_active("SDWAN") - False - - Note: - This only checks for the presence of the URL variable. It does not - validate credentials or test connectivity. For full credential - validation, use the detect_controller_type() function from - nac_test.utils.controller instead. - """ - url_var = f"{arch}_URL" - value = os.environ.get(url_var) - return bool(value and value.strip()) diff --git a/nac_test/combined_orchestrator.py b/nac_test/combined_orchestrator.py index d39e0572..dc84025f 100644 --- a/nac_test/combined_orchestrator.py +++ b/nac_test/combined_orchestrator.py @@ -14,7 +14,6 @@ display_auth_failure_banner, display_unreachable_banner, ) -from nac_test.cli.validators import AuthOutcome, preflight_auth_check from nac_test.core.constants import ( COMBINED_SUMMARY_FILENAME, HTML_REPORTS_DIRNAME, @@ -28,10 +27,17 @@ SUMMARY_SEPARATOR_WIDTH, XUNIT_XML, ) +from nac_test.core.controller import ( + ResolutionError, + format_resolution_error, + resolve_controller, +) +from nac_test.core.controller_auth import preflight_auth_check +from nac_test.core.error_classification import AuthOutcome from nac_test.core.reporting.combined_generator import CombinedReportGenerator from nac_test.core.types import ( CombinedResults, - ControllerTypeKey, + ControllerContext, PreFlightFailure, PreFlightFailureType, TestResults, @@ -41,7 +47,6 @@ from nac_test.pyats_core.orchestrator import PyATSOrchestrator from nac_test.robot.orchestrator import RobotOrchestrator from nac_test.utils.cleanup import cleanup_stale_test_artifacts -from nac_test.utils.controller import detect_controller_type, get_env_var_prefix from nac_test.utils.logging import DEFAULT_LOGLEVEL, LogLevel from nac_test.utils.platform import check_and_exit_if_unsupported_macos_python from nac_test.utils.terminal import terminal @@ -143,8 +148,8 @@ def __init__( self.dev_robot_only = dev_robot_only self.verbose = verbose - # Controller type — detected lazily in run_tests() when PyATS tests are present - self.controller_type: ControllerTypeKey | None = None + # Controller context — resolved lazily in run_tests() when PyATS tests are present + self.controller_context: ControllerContext | None = None def run_tests(self) -> CombinedResults: """Main entry point for combined test execution. @@ -218,7 +223,7 @@ def run_tests(self) -> CombinedResults: output_dir=self.output_dir, minimal_reports=self.minimal_reports, custom_testbed_path=self.custom_testbed_path, - controller_type=self.controller_type, + controller_context=self.controller_context, dry_run=self.dry_run, verbose=self.verbose, loglevel=self.loglevel, @@ -340,11 +345,14 @@ def _run_pre_flight_checks(self, combined_results: CombinedResults) -> bool: should skip PyATS execution), ``False`` when all checks passed. """ try: - self.controller_type = detect_controller_type() - logger.info(f"Controller type detected: {self.controller_type}") - except ValueError as e: + self.controller_context = resolve_controller() + logger.info( + "Controller type detected: %s", self.controller_context.controller_type + ) + except ResolutionError as e: + detail = format_resolution_error(e) typer.secho( - f"\n❌ Controller detection failed:\n{e}", + f"\n❌ Controller detection failed:\n{detail}", fg=typer.colors.RED, err=True, ) @@ -352,11 +360,11 @@ def _run_pre_flight_checks(self, combined_results: CombinedResults) -> bool: failure_type=PreFlightFailureType.DETECTION, controller_type=None, controller_url=None, - detail=str(e), + detail=detail, ) return True - auth_result = preflight_auth_check(self.controller_type) + auth_result = preflight_auth_check(self.controller_context) if not auth_result.success: typer.echo("") if auth_result.reason == AuthOutcome.UNREACHABLE: @@ -366,12 +374,10 @@ def _run_pre_flight_checks(self, combined_results: CombinedResults) -> bool: detail=auth_result.detail, ) else: - env_var_prefix = get_env_var_prefix(auth_result.controller_type) display_auth_failure_banner( controller_type=auth_result.controller_type, controller_url=auth_result.controller_url, detail=auth_result.detail, - env_var_prefix=env_var_prefix, ) typer.echo("") diff --git a/nac_test/core/auth_cache.py b/nac_test/core/auth_cache.py new file mode 100644 index 00000000..0bfb1d32 --- /dev/null +++ b/nac_test/core/auth_cache.py @@ -0,0 +1,219 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2025 Daniel Schmidt + +"""Generic file-based authentication token caching for parallel processes.""" + +import hashlib +import json +import logging +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any + +from filelock import FileLock + +from nac_test.core.constants import AUTH_CACHE_DIR + +logger = logging.getLogger(__name__) + + +class AuthCache: + """Generic file-based auth token caching across parallel processes + + This is controller-agnostic - each architecture provides their own auth function + """ + + @classmethod + def _cache_auth_data( + cls, + controller_type: str, + url: str, + auth_func: Callable[[], tuple[Any, int]], + extract_token: bool = False, + ) -> Any: + """Internal method for caching auth data with file-based locking. + + Args: + controller_type: Type of controller + url: Controller URL + auth_func: Function that returns (auth_data, expires_in_seconds) + extract_token: If True, expects auth_data to be a string token. + If False, expects a dict. + + Returns: + Either a token string or auth dict based on extract_token flag + """ + cache_dir = Path(AUTH_CACHE_DIR) + cache_dir.mkdir(exist_ok=True) + + url_hash = hashlib.md5(url.encode(), usedforsecurity=False).hexdigest() + cache_file = cache_dir / f"{controller_type}_{url_hash}.json" + lock_file = cache_dir / f"{controller_type}_{url_hash}.lock" + + with FileLock(str(lock_file)): + # Check if valid cached data exists + if cache_file.exists(): + try: + with open(cache_file, encoding="utf-8") as f: + data = json.load(f) + if time.time() < data["expires_at"]: + # Return based on what type of data we're working with + if extract_token: + return str(data["token"]) + else: + # Return the auth_data dict (minus expires_at) + return { + k: v for k, v in data.items() if k != "expires_at" + } + except json.JSONDecodeError as e: + logger.warning( + "Invalid JSON in cache file %s, will recreate: %s", + cache_file, + e, + ) + except KeyError as e: + logger.warning( + "Missing key in cache file %s, will recreate: %s", + cache_file, + e, + ) + except TypeError as e: + logger.warning( + "Type error reading cache file %s, will recreate: %s", + cache_file, + e, + ) + + # Get new auth data + auth_data, expires_in = auth_func() + + # Prepare cache data + cache_data: dict[str, Any] = {"expires_at": time.time() + expires_in - 60} + + if extract_token: + # Legacy token mode - auth_data is a string + cache_data["token"] = str(auth_data) + result: Any = str(auth_data) + else: + # Generic dict mode - merge auth_data dict + auth_dict = ( + dict(auth_data) if not isinstance(auth_data, dict) else auth_data + ) + cache_data.update(auth_dict) + result = auth_dict + + # Cache it + with open(cache_file, "w", encoding="utf-8") as f: + json.dump(cache_data, f) + + cache_file.chmod(0o600) + return result + + @classmethod + def invalidate(cls, controller_type: str, url: str) -> None: + """Remove the cached auth data for a given controller type and URL. + + This is a best-effort operation: if the cache file does not exist or + cannot be deleted, a debug message is logged and no exception is raised. + Both the cache file and its associated lock file are cleaned up. + + Args: + controller_type: Type of controller (e.g., "ACI", "SDWAN_MANAGER", "CC"). + url: Controller URL used to derive the cache file path. + """ + cache_dir = Path(AUTH_CACHE_DIR) + url_hash = hashlib.md5(url.encode(), usedforsecurity=False).hexdigest() + cache_file = cache_dir / f"{controller_type}_{url_hash}.json" + lock_file = cache_dir / f"{controller_type}_{url_hash}.lock" + + try: + with FileLock(str(lock_file)): + if cache_file.exists(): + cache_file.unlink() + logger.debug( + "Invalidated auth cache for %s at %s", controller_type, url + ) + else: + logger.debug( + "No auth cache to invalidate for %s at %s", + controller_type, + url, + ) + except Exception as e: + logger.debug( + "Best-effort cache invalidation failed for %s at %s: %s", + controller_type, + url, + e, + ) + return + + # Clean up the lock file after releasing the lock + try: + if lock_file.exists(): + lock_file.unlink() + except Exception as e: + logger.debug("Could not remove lock file %s: %s", lock_file, e) + + @classmethod + def get_or_create( + cls, + controller_type: str, + url: str, + auth_func: Callable[[], tuple[dict[str, Any], int]], + ) -> dict[str, Any]: + """Get existing auth data dict or create new one with file-based locking. + + Generic method for caching any JSON-serializable dict. + + Args: + controller_type: Type of controller (SDWAN_MANAGER, CC, etc) + url: Controller URL + auth_func: Function that returns (auth_dict, expires_in_seconds) + + Returns: + Dict containing authentication data (without expires_at) + """ + result = cls._cache_auth_data( + controller_type=controller_type, + url=url, + auth_func=auth_func, + extract_token=False, + ) + # Type narrowing for mypy - we know it's a dict when extract_token=False + assert isinstance(result, dict) + return result + + @classmethod + def get_or_create_token( + cls, + controller_type: str, + url: str, + username: str, + password: str, + auth_func: Callable[[str, str, str], tuple[str, int]], + ) -> str: + """Get existing token or create new one with file-based locking + + Args: + controller_type: Type of controller (APIC, CC, etc) + url: Controller URL + username: Username for authentication + password: Password for authentication + auth_func: Architecture-specific auth function that returns (token, expires_in_seconds) + """ + + # Create a wrapper function that captures the username/password + def wrapped_auth_func() -> tuple[str, int]: + return auth_func(url, username, password) + + result = cls._cache_auth_data( + controller_type=controller_type, + url=url, + auth_func=wrapped_auth_func, + extract_token=True, + ) + # Type narrowing for mypy - we know it's a str when extract_token=True + assert isinstance(result, str) + return result diff --git a/nac_test/core/constants.py b/nac_test/core/constants.py index ddc0c334..03ecca80 100644 --- a/nac_test/core/constants.py +++ b/nac_test/core/constants.py @@ -3,8 +3,10 @@ """Core constants shared across the nac-test framework.""" +import os import platform import sys +import tempfile from nac_test._env import get_bool_env, get_positive_numeric_env @@ -108,6 +110,12 @@ # Service unavailable status codes (treat as unreachable) HTTP_SERVICE_UNAVAILABLE_CODES: tuple[int, ...] = (408, 429, 503, 504) +# Controller context env var (orchestrator writes, subprocess reads) +ENV_CONTROLLER_CONTEXT: str = "NAC_TEST_CONTROLLER_CONTEXT" + +# Auth cache directory (file-based token caching for parallel processes) +AUTH_CACHE_DIR: str = os.path.join(tempfile.gettempdir(), "nac-test-auth-cache") + # Platform detection IS_MACOS: bool = platform.system() == "Darwin" IS_WINDOWS: bool = platform.system() == "Windows" diff --git a/nac_test/core/controller.py b/nac_test/core/controller.py new file mode 100644 index 00000000..31340bc3 --- /dev/null +++ b/nac_test/core/controller.py @@ -0,0 +1,940 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2025 Daniel Schmidt + +"""Controller type detection utilities for NAC test framework. + +This module provides utilities for detecting which network controller type (architecture) +is being targeted based on environment variables. Controller credentials are required for +ALL test types (both API and D2D tests) as they determine the architecture context. + +The detection logic ensures exactly one controller type is configured at a time to prevent +ambiguous test execution contexts. + +The module also provides a mapping from controller types to their defaults block prefixes, +enabling automatic defaults resolution without per-architecture configuration. For example, +when ACI_URL is detected, the framework automatically knows to look for defaults.apic in +the merged NAC data model. +""" + +import logging +import os +import warnings +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from types import MappingProxyType +from typing import cast + +from nac_test._env import is_env_var_set +from nac_test.core.constants import ENV_CONTROLLER_CONTEXT +from nac_test.core.types import ( + AuthMethod, + ControllerContext, + ControllerTypeKey, + CredentialKind, +) +from nac_test.exceptions import NacTestError + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class CredentialSet: + """A single credential combination that can authenticate to a controller. + + Each set is self-contained: if ALL env_vars are present and non-empty, + the controller is considered fully configured. When a controller has + multiple CredentialSets, the first satisfied set wins (order matters). + + Attributes: + fields: Mapping of semantic kind -> environment variable name, + e.g. ``{"url": "ACI_URL", "username": "ACI_USERNAME", + "password": "ACI_PASSWORD"}``. ``env_vars`` and ``kinds`` are + derived from this single mapping, so they can never drift out of + sync with each other. Lookups by kind (e.g. :func:`get_controller_url`, + :func:`get_connection_params`) key off ``fields`` directly, so entry + order within the mapping is not significant. + label: Human-readable label for error messages (e.g., "API Token (20.18+)"). + auth_method: Identifier consumed by auth adapters in nac-test-pyats-common + to select the authentication mechanism (e.g., "token", "session"). + """ + + fields: Mapping[CredentialKind, str] + label: str + auth_method: AuthMethod = AuthMethod.SESSION + + def __post_init__(self) -> None: + # Normalize to an immutable mapping so a frozen CredentialSet can't be + # mutated in place via its `fields` dict. + object.__setattr__(self, "fields", MappingProxyType(dict(self.fields))) + + @property + def env_vars(self) -> tuple[str, ...]: + """Environment variable names, in ``fields`` order.""" + return tuple(self.fields.values()) + + @property + def kinds(self) -> tuple[CredentialKind, ...]: + """Semantic kind for each entry in ``env_vars``, in the same order.""" + return tuple(self.fields.keys()) + + +@dataclass(frozen=True) +class ControllerConfig: + """Configuration metadata for a supported controller type. + + Attributes: + display_name: User-facing name (e.g., "APIC", "Catalyst Center"). + url_env_var: Environment variable name for the controller URL. + env_var_prefix: Prefix for credential env vars (e.g., "ACI" → ACI_USERNAME). + credential_sets: Ordered list of credential combinations. The first set + whose env_vars are all present and non-empty wins. Every controller + must have at least one CredentialSet. + defaults_prefix: JMESPath prefix for the defaults block in NAC data models + (e.g., "defaults.apic", "defaults.sdwan"). + insecure_env_var: Environment variable name for the SSL-verification-disable + toggle (e.g., "ACI_INSECURE"). See :func:`should_verify_ssl`. + cache_key: The controller_type string passed to AuthCache by the auth adapter. + None for controllers that don't have an auth adapter in nac-test-pyats-common. + """ + + display_name: str + url_env_var: str + env_var_prefix: str + credential_sets: tuple[CredentialSet, ...] + defaults_prefix: str + insecure_env_var: str + cache_key: str | None = None + + +# Single source of truth for all controller configurations +# Replaces the registry from controller_auth.py +CONTROLLER_REGISTRY: dict[str, ControllerConfig] = { + "ACI": ControllerConfig( + display_name="APIC", + url_env_var="ACI_URL", + env_var_prefix="ACI", + credential_sets=( + CredentialSet( + fields={ + "url": "ACI_URL", + "username": "ACI_USERNAME", + "password": "ACI_PASSWORD", + }, + label="Username/Password", + ), + ), + defaults_prefix="defaults.apic", + insecure_env_var="ACI_INSECURE", + cache_key="ACI", + ), + "SDWAN": ControllerConfig( + display_name="SDWAN Manager", + url_env_var="SDWAN_URL", + env_var_prefix="SDWAN", + credential_sets=( + CredentialSet( + fields={ + "url": "SDWAN_URL", + "token": "SDWAN_API_TOKEN", + }, + label="API Token (20.18+)", + auth_method=AuthMethod.TOKEN, + ), + CredentialSet( + fields={ + "url": "SDWAN_URL", + "username": "SDWAN_USERNAME", + "password": "SDWAN_PASSWORD", + }, + label="Username/Password", + ), + ), + defaults_prefix="defaults.sdwan", + insecure_env_var="SDWAN_INSECURE", + cache_key="SDWAN_MANAGER", + ), + "CC": ControllerConfig( + display_name="Catalyst Center", + url_env_var="CC_URL", + env_var_prefix="CC", + credential_sets=( + CredentialSet( + fields={ + "url": "CC_URL", + "username": "CC_USERNAME", + "password": "CC_PASSWORD", + }, + label="Username/Password", + ), + ), + defaults_prefix="defaults.catc", + insecure_env_var="CC_INSECURE", + cache_key="CC", + ), + "MERAKI": ControllerConfig( + display_name="Meraki", + url_env_var="MERAKI_URL", + env_var_prefix="MERAKI", + credential_sets=( + CredentialSet( + fields={ + "url": "MERAKI_URL", + "username": "MERAKI_USERNAME", + "password": "MERAKI_PASSWORD", + }, + label="Username/Password", + ), + ), + defaults_prefix="defaults.meraki", + insecure_env_var="MERAKI_INSECURE", + ), + "FMC": ControllerConfig( + display_name="Firepower Management Center", + url_env_var="FMC_URL", + env_var_prefix="FMC", + credential_sets=( + CredentialSet( + fields={ + "url": "FMC_URL", + "username": "FMC_USERNAME", + "password": "FMC_PASSWORD", + }, + label="Username/Password", + ), + ), + defaults_prefix="defaults.fmc", + insecure_env_var="FMC_INSECURE", + ), + "ISE": ControllerConfig( + display_name="ISE", + url_env_var="ISE_URL", + env_var_prefix="ISE", + credential_sets=( + CredentialSet( + fields={ + "url": "ISE_URL", + "username": "ISE_USERNAME", + "password": "ISE_PASSWORD", + }, + label="Username/Password", + ), + ), + defaults_prefix="defaults.ise", + insecure_env_var="ISE_INSECURE", + ), + "IOSXE": ControllerConfig( + display_name="IOS XE", + url_env_var="IOSXE_URL", + env_var_prefix="IOSXE", + # Direct device access, no controller credentials required. + # IOSXE_URL and IOSXE_HOST serve the same purpose (IOSXE_URL is + # being phased out) - both map to kind="url". + credential_sets=( + CredentialSet( + fields={ + "url": "IOSXE_URL", + "username": "IOSXE_USERNAME", + "password": "IOSXE_PASSWORD", + }, + label="Device URL", + ), + CredentialSet( + fields={ + "url": "IOSXE_HOST", + "username": "IOSXE_USERNAME", + "password": "IOSXE_PASSWORD", + }, + label="Device Host", + ), + ), + defaults_prefix="defaults.iosxe", + insecure_env_var="IOSXE_INSECURE", + ), +} + +# Module-level cache for the credential set that was matched during detection. +# Populated by detect_controller_type(), consumed by get_matched_credential_set(). +_matched_credential_sets: dict[str, CredentialSet] = {} + + +class ResolutionError(NacTestError): + """Base for controller resolution failures.""" + + +class NoCredentialsFound(ResolutionError): + """No controller env vars detected at all.""" + + +class MultipleControllersFound(ResolutionError): + """Multiple controller types have complete credentials configured.""" + + def __init__(self, controllers: list[str]): + self.controllers = controllers + super().__init__( + f"Multiple controller credentials detected: {', '.join(controllers)}" + ) + + +class IncompleteCredentials(ResolutionError): + """Some controller env vars present but no complete credential set.""" + + def __init__(self, partial_controllers: list[str] | list[ControllerTypeKey]): + self.partial_controllers = partial_controllers + super().__init__( + f"Incomplete credentials for: {', '.join(partial_controllers)}" + ) + + +def resolve_controller() -> ControllerContext: + """Resolve the active controller from environment variables. + + Single source of truth for controller detection. Returns a + :class:`ControllerContext` on success; raises a typed + :class:`ResolutionError` subclass on failure. The caller decides + how to handle failures — this function never calls ``sys.exit()``. + + Side-effects: + * Populates ``_matched_credential_sets`` (same as the legacy + ``detect_controller_type()``). + + Returns: + ControllerContext with ``controller_type`` and ``auth_method``. + + Raises: + NoCredentialsFound: No controller env vars detected at all. + MultipleControllersFound: More than one controller fully configured. + IncompleteCredentials: Some env vars present but no complete set. + """ + logger.debug("Resolving controller from environment") + complete, partial = _find_credential_sets() + + if len(complete) > 1: + raise MultipleControllersFound(list(complete.keys())) + + if not complete and not partial: + raise NoCredentialsFound("No controller credentials found in environment.") + + if not complete and partial: + raise IncompleteCredentials(partial) + + # Exactly one complete set — success + controller_type = next(iter(complete)) + matched_cred_set = complete[controller_type] + _matched_credential_sets[controller_type] = matched_cred_set + + ctx = ControllerContext( + controller_type=controller_type, + auth_method=matched_cred_set.auth_method, + ) + + logger.info( + "Resolved controller: %s (auth_method=%s)", + controller_type, + matched_cred_set.auth_method, + ) + return ctx + + +def get_controller_context() -> ControllerContext: + """Get the resolved controller context in a subprocess. + + This function is designed for use by ``NACTestBase.setup()`` in PyATS + subprocesses. The parent process (``CombinedOrchestrator``) resolves the + controller via ``resolve_controller()`` and passes the result to + ``PyATSOrchestrator``, which serializes it to ``NAC_TEST_CONTROLLER_CONTEXT`` + before launching subprocesses. + + **Primary path (subprocess):** Deserializes from ``NAC_TEST_CONTROLLER_CONTEXT`` + environment variable set by ``PyATSOrchestrator``. + + **Fallback (transitional):** If the env var is absent, falls back to + ``detect_controller_type()`` for backwards compatibility. This fallback + will be removed in Phase 3 once all consumers are migrated. + + Returns: + ControllerContext with controller_type and auth_method. + + Raises: + ValueError: If no controller credentials are found (via fallback path). + """ + raw = os.environ.get(ENV_CONTROLLER_CONTEXT) + if raw: + return ControllerContext.from_json(raw) + + # --- Transitional fallback (remove in Phase 3) ----------------------- + logging.getLogger(__name__).info( + "NAC_TEST_CONTROLLER_CONTEXT not set — falling back to " + "detect_controller_type(). This fallback will be removed in a " + "future release." + ) + + controller_type = detect_controller_type() + return ControllerContext( + controller_type=controller_type, + auth_method=_infer_auth_method(controller_type), + ) + + +def format_resolution_error(error: ResolutionError) -> str: + """Format a :class:`ResolutionError` into a user-facing message. + + Re-uses the existing detailed error formatters so that CLI output + stays identical to the legacy ``detect_controller_type()`` path. + """ + if isinstance(error, MultipleControllersFound): + return _format_multiple_credentials_error(error.controllers) + if isinstance(error, IncompleteCredentials): + return _format_incomplete_credentials_error(error.partial_controllers) + if isinstance(error, NoCredentialsFound): + return _format_no_credentials_error() + return str(error) + + +def get_display_name(controller_type: str) -> str: + """Get the user-facing display name for a controller type. + + Looks up the display name from CONTROLLER_REGISTRY. If the controller type + is not registered, returns the controller_type string as-is for graceful + degradation. + + Args: + controller_type: The internal controller type key (e.g., "ACI", "SDWAN", "CC"). + + Returns: + The user-facing display name (e.g., "APIC", "SDWAN Manager", "Catalyst Center"), + or the controller_type string if not found in registry. + """ + config = CONTROLLER_REGISTRY.get(controller_type) + return config.display_name if config else controller_type + + +def get_env_var_prefix(controller_type: str) -> str: + """Get the environment variable prefix for a controller type. + + Looks up the env_var_prefix from CONTROLLER_REGISTRY. If the controller type + is not registered, returns the controller_type string as-is for graceful + degradation. + + Args: + controller_type: The internal controller type key (e.g., "ACI", "SDWAN", "CC"). + + Returns: + The environment variable prefix (e.g., "ACI", "SDWAN", "CC"), + or the controller_type string if not found in registry. + """ + config = CONTROLLER_REGISTRY.get(controller_type) + return config.env_var_prefix if config else controller_type + + +def get_defaults_prefix(controller_type: str) -> str: + """Get the JMESPath defaults prefix for a controller type. + + Looks up the defaults_prefix from CONTROLLER_REGISTRY. If the controller type + is not registered, constructs a default prefix of "defaults." + for graceful degradation. + + Args: + controller_type: The internal controller type key (e.g., "ACI", "SDWAN", "CC"). + + Returns: + The JMESPath defaults prefix (e.g., "defaults.apic", "defaults.sdwan"), + or "defaults." if not found in registry. + + Example: + >>> get_defaults_prefix("ACI") + 'defaults.apic' + >>> get_defaults_prefix("SDWAN") + 'defaults.sdwan' + >>> get_defaults_prefix("UNKNOWN") + 'defaults.unknown' + """ + config = CONTROLLER_REGISTRY.get(controller_type) + return config.defaults_prefix if config else f"defaults.{controller_type.lower()}" + + +def get_controller_url(controller_type: str) -> str: + """Get the controller URL from environment variables. + + Iterates through credential sets in order, returning the first env var value + found. This follows the same first-match-wins pattern as _find_credential_sets. + + Args: + controller_type: The internal controller type key (e.g., "ACI", "SDWAN", "IOSXE"). + + Returns: + The controller URL value from the environment. + + Raises: + KeyError: If no credential set env var has a URL value set. + + Example: + >>> os.environ["ACI_URL"] = "https://apic.example.com" + >>> get_controller_url("ACI") + 'https://apic.example.com' + + >>> os.environ["IOSXE_HOST"] = "192.168.1.1" + >>> get_controller_url("IOSXE") # Returns IOSXE_HOST when IOSXE_URL not set + '192.168.1.1' + """ + config = CONTROLLER_REGISTRY.get(controller_type) + + if config is None: + # Fallback for unknown controller types + return os.environ[f"{controller_type}_URL"] + + # Primary URL from the explicit url_env_var field + value = os.environ.get(config.url_env_var, "").strip() + if value: + return value + + # Fallback for alternative URL vars (e.g., IOSXE_HOST) + for cred_set in config.credential_sets: + url_var = cred_set.fields.get("url") + if url_var and url_var != config.url_env_var: + alt = os.environ.get(url_var, "").strip() + if alt: + return alt + + raise KeyError(config.url_env_var) + + +def get_credential_vars( + controller_type: str, +) -> list[tuple[CredentialKind, str]]: + """Return deduplicated (kind, env_var) pairs for non-URL credential fields. + + Iterates all credential sets for the given controller type and collects + unique credential fields, excluding URL (which is accessed separately + via :func:`get_controller_url`). + + This is the single-source primitive used by CLI banners and HTML report + templates to generate remediation instructions. + + Args: + controller_type: The internal controller type key (e.g., "ACI", "SDWAN"). + + Returns: + List of (kind, env_var_name) tuples, deduplicated by var name. + E.g. ``[("username", "ACI_USERNAME"), ("password", "ACI_PASSWORD")]`` + or ``[("token", "SDWAN_API_TOKEN"), ("username", "SDWAN_USERNAME"), ...]`` + + Raises: + KeyError: If controller_type is not registered. + """ + config = CONTROLLER_REGISTRY[controller_type] + seen: set[str] = set() + result: list[tuple[CredentialKind, str]] = [] + for cred_set in config.credential_sets: + for kind, var in cred_set.fields.items(): + if kind == "url" or var in seen: + continue + seen.add(var) + result.append((kind, var)) + return result + + +_INSECURE_TRUE_VALUES = ("true", "1", "yes") + + +def should_verify_ssl(controller_type: str, default: bool = False) -> bool: + """Determine whether SSL certificate verification should be enabled. + + Single source of truth for reading a controller's ``{PREFIX}_INSECURE`` + environment variable (see :attr:`ControllerConfig.insecure_env_var`), + replacing per-adapter ``os.environ.get(f"{prefix}_INSECURE", ...)`` calls + in nac-test-pyats-common. + + Args: + controller_type: The internal controller type key (e.g., "ACI", "SDWAN", "CC"). + default: Value to use when the env var is unset or empty. Defaults to + False (skip verification), matching existing adapter behavior to + keep lab/self-signed deployments working without extra config. + + Returns: + True if SSL verification should be performed. + + Raises: + KeyError: If controller_type is not registered. + + Example: + >>> os.environ["ACI_INSECURE"] = "true" + >>> should_verify_ssl("ACI") + False + >>> os.environ["ACI_INSECURE"] = "false" + >>> should_verify_ssl("ACI") + True + """ + config = CONTROLLER_REGISTRY[controller_type] + raw = os.environ.get(config.insecure_env_var, "").strip() + if not raw: + return default + # Env var is "{PREFIX}_INSECURE": true/1/yes means insecure (don't verify) + return raw.lower() not in _INSECURE_TRUE_VALUES + + +def get_connection_params( + controller_type: str, auth_method: AuthMethod +) -> dict[CredentialKind, str]: + """Resolve connection values by kind for a controller_type/auth_method. + + Single source of truth for reading connection-related env var values. + Looks up the ``CredentialSet``(s) in ``CONTROLLER_REGISTRY[controller_type]`` + whose ``auth_method`` matches, then reads each of its ``env_vars`` from + ``os.environ``, keyed by the corresponding entry in ``kinds`` (e.g. + ``"url"``, ``"username"``, ``"password"``, ``"token"``). + + When multiple credential sets share the same ``auth_method`` (e.g. IOS-XE's + URL and Host variants, both ``auth_method="session"``), the first one that + is fully satisfied (all env vars present) is used, so it doesn't matter + which of the equivalent variables the caller actually configured. If none + are fully satisfied, the first matching set is used to build the + "missing variable(s)" error, matching prior behavior. + + Args: + controller_type: The internal controller type key (e.g., "ACI", "SDWAN", "CC"). + auth_method: The auth method to match against the controller's + credential sets (e.g., "session", "token"). + + Returns: + Dict keyed by kind, e.g. ``{"url": ..., "username": ..., "password": ...}`` + or ``{"url": ..., "token": ...}``. + + Raises: + KeyError: If controller_type is not registered. + ValueError: If no credential set matches auth_method, or if any of its + env vars are missing/empty (message lists the missing var names). + + Example: + >>> os.environ.update({"SDWAN_URL": "https://vmanage.example.com", + ... "SDWAN_API_TOKEN": "abc.def.ghi"}) + >>> get_connection_params("SDWAN", "token") + {'url': 'https://vmanage.example.com', 'token': 'abc.def.ghi'} + """ + config = CONTROLLER_REGISTRY[controller_type] + + candidates = [cs for cs in config.credential_sets if cs.auth_method == auth_method] + if not candidates: + raise ValueError( + f"No credential set for controller_type={controller_type!r} matches " + f"auth_method={auth_method!r}." + ) + + # Prefer a fully-satisfied candidate so equivalent variables (e.g. IOS-XE's + # IOSXE_URL vs IOSXE_HOST, both auth_method="session") don't collide - + # whichever one the caller actually configured wins. If none is fully + # satisfied, report missing vars for whichever candidate the caller has + # actually started configuring (most env vars already set), so a user who + # set IOSXE_HOST but forgot the password isn't told to set IOSXE_URL + # instead. Ties (including "nothing configured at all") keep the first + # candidate, matching prior behavior. + def _set_var_count(cs: CredentialSet) -> int: + return sum(1 for var in cs.env_vars if os.environ.get(var, "").strip()) + + cred_set = next( + ( + cs + for cs in candidates + if all(os.environ.get(var, "").strip() for var in cs.env_vars) + ), + None, + ) + if cred_set is None: + cred_set = max(candidates, key=_set_var_count) + + values: dict[CredentialKind, str] = {} + missing: list[str] = [] + for kind, var in cred_set.fields.items(): + value = os.environ.get(var, "").strip() + if value: + values[kind] = value + else: + missing.append(var) + + if missing: + raise ValueError( + f"Missing required environment variable(s) for {controller_type}: " + f"{', '.join(missing)}" + ) + + return values + + +def detect_controller_type() -> ControllerTypeKey: + """Detect the controller type based on environment variables. + + .. deprecated:: + This function is retained for backwards compatibility with external + packages (e.g., ``nac-test-pyats-common``) that have not yet migrated + to :func:`resolve_controller`. New code should use ``resolve_controller()`` + directly and handle :class:`ResolutionError` subtypes. This function + will be removed once all consumers have migrated. + + This function examines environment variables to determine which network controller + architecture is being targeted. It ensures exactly one controller type has credentials + configured to prevent ambiguous test contexts. + + Controller credentials are required for ALL test types: + - API tests: Use credentials directly for controller authentication + - D2D tests: Use controller type to determine device resolution logic + + Returns: + The detected controller type (e.g., "ACI", "SDWAN", "CC", "MERAKI", "FMC", "ISE"). + + Raises: + ValueError: If no controller credentials are found, multiple controllers are + configured, or credentials are incomplete. + + Example: + >>> os.environ.update({"ACI_URL": "https://apic.local", + ... "ACI_USERNAME": "admin", + ... "ACI_PASSWORD": "pass"}) + >>> controller = detect_controller_type() + >>> print(controller) + "ACI" + + Note: + This function delegates to :func:`resolve_controller` and converts typed + exceptions to ``ValueError`` for backwards compatibility with existing callers. + """ + warnings.warn( + "detect_controller_type() is deprecated; use resolve_controller() instead.", + DeprecationWarning, + stacklevel=2, + ) + try: + ctx = resolve_controller() + return ctx.controller_type + except ResolutionError as e: + raise ValueError(format_resolution_error(e)) from e + + +def get_matched_credential_set(controller_type: str) -> CredentialSet | None: + """Get the credential set that was matched during controller detection. + + .. deprecated:: + This function is a transitional API for ``nac-test-pyats-common`` auth + adapters. It will be removed in Phase 3 once auth adapters migrate to + using ``get_controller_context().auth_method`` directly. New code should + not use this function. + + Returns the CredentialSet that satisfied detection for the given controller + type. This is populated by detect_controller_type() / resolve_controller() + and is intended for use by auth adapters in nac-test-pyats-common to + determine which authentication mechanism to use. + + Args: + controller_type: The controller type key (e.g., "SDWAN", "ACI"). + + Returns: + The matched CredentialSet, or None if detection has not been called + or the controller type was not detected. + """ + warnings.warn( + "get_matched_credential_set() is deprecated; use " + "get_controller_context().auth_method instead.", + DeprecationWarning, + stacklevel=2, + ) + return _matched_credential_sets.get(controller_type) + + +def _find_credential_sets() -> tuple[ + dict[ControllerTypeKey, CredentialSet], + list[ControllerTypeKey], +]: + """Find complete and partial credential sets in environment. + + For each controller, iterates through its credential_sets in order. The first + set whose env_vars are all present and non-empty marks the controller as + complete. If no set is fully satisfied but at least one variable from any set + is present, the controller is reported as partial. + + Returns: + A tuple containing: + - Dictionary mapping complete controller types to the winning CredentialSet + - List of controller types with partial credentials + """ + complete: dict[ControllerTypeKey, CredentialSet] = {} + partial: list[ControllerTypeKey] = [] + + for controller_type, config in CONTROLLER_REGISTRY.items(): + found_complete = False + has_any_var = False + ct_key = cast(ControllerTypeKey, controller_type) + + for cred_set in config.credential_sets: + all_present = True + + for var in cred_set.env_vars: + if is_env_var_set(var): + has_any_var = True + logger.debug(f" {controller_type}: Found {var}") + else: + all_present = False + + if all_present: + complete[ct_key] = cred_set + logger.debug(f" {controller_type}: Complete via {cred_set.label}") + found_complete = True + break + + if not found_complete and has_any_var: + partial.append(ct_key) + + return complete, partial + + +def _infer_auth_method(controller_type: str) -> AuthMethod: + """Infer auth_method by scanning env vars for a controller type. + + Used only in the transitional fallback path of + ``get_controller_context()`` when ``NAC_TEST_CONTROLLER_CONTEXT`` + is absent. Mirrors the logic of ``_find_credential_sets()`` but + returns only the auth_method string. + """ + config = CONTROLLER_REGISTRY.get(controller_type) + if config is None: + return AuthMethod.SESSION + for cred_set in config.credential_sets: + if all(is_env_var_set(v) for v in cred_set.env_vars): + return cred_set.auth_method + return AuthMethod.SESSION + + +def _format_incomplete_credentials_error(partial_controllers: Sequence[str]) -> str: + """Format error message for incomplete controller credentials. + + Creates a detailed error message listing each partially configured + controller and its accepted credential sets, so the user knows + exactly which variables are needed. + + Args: + partial_controllers: List of controller types with partial credentials. + + Returns: + Formatted error message with accepted credential sets. + + Example: + >>> error = _format_incomplete_credentials_error(["SDWAN"]) + >>> print(error) + Incomplete controller credentials detected: + ... + """ + lines_parts: list[str] = [] + for controller in partial_controllers: + config = CONTROLLER_REGISTRY[controller] + set_descriptions = [ + f"{cs.label}: {' + '.join(cs.env_vars)}" for cs in config.credential_sets + ] + line = f"{controller}: incomplete credentials" + line += "\n Accepted credential sets:\n" + line += "\n".join(f" - {desc}" for desc in set_descriptions) + lines_parts.append(line) + lines = "\n".join(f" - {info}" for info in lines_parts) + return ( + f"Incomplete controller credentials detected:\n" + f"{lines}\n\n" + f"Please provide all required variables for one of the " + f"accepted credential sets listed above." + ) + + +def _format_multiple_credentials_error(controllers: list[str]) -> str: + """Format error message for multiple controller credentials. + + Creates a detailed error message with remediation options when multiple + controller types have complete credentials configured. + + Args: + controllers: List of controller types with complete credentials. + + Returns: + Formatted error message with remediation steps. + + Example: + >>> error = _format_multiple_credentials_error(["ACI", "SDWAN"]) + >>> print(error) + Multiple controller credentials detected: ACI, SDWAN + ... + """ + controller_list = ", ".join(controllers) + + message = ( + f"Multiple controller credentials detected: {controller_list}\n\n" + f"The test framework requires exactly one controller type to be configured.\n\n" + f"Remediation options:\n" + f"1. Keep only one controller's credentials and unset the others:\n" + ) + + # Collect all env vars per controller (union of all credential sets) + def _all_env_vars(controller: str) -> list[str]: + config = CONTROLLER_REGISTRY[controller] + seen: set[str] = set() + result: list[str] = [] + for cs in config.credential_sets: + for v in cs.env_vars: + if v not in seen: + seen.add(v) + result.append(v) + return result + + # Add specific unset commands for each controller + for controller in controllers: + other_controllers = [c for c in controllers if c != controller] + vars_to_remove = [] + for other in other_controllers: + vars_to_remove.extend(_all_env_vars(other)) + + unset_command = f" unset {' '.join(vars_to_remove)}" + message += f"\n To use {controller} only:\n{unset_command}\n" + + message += ( + "\n2. Use a separate shell session for each controller type\n" + "\n3. Use environment variable management tools (direnv, dotenv) to switch contexts" + ) + + return message + + +def _format_no_credentials_error() -> str: + """Format error message when no controller credentials are found. + + Creates a detailed error message with setup instructions when no controller + credentials are detected in the environment. + + Returns: + Formatted error message with setup guidance. + + Example: + >>> error = _format_no_credentials_error() + >>> print(error) + No controller credentials found in environment. + ... + """ + message = ( + "No controller credentials found in environment.\n\n" + "Controller credentials are required for ALL test types (API and D2D).\n" + "The framework uses these to determine the architecture context.\n\n" + "Please set environment variables for ONE of the following controller types:\n\n" + ) + + for controller_type, config in CONTROLLER_REGISTRY.items(): + message += f"{controller_type}:\n" + for i, cred_set in enumerate(config.credential_sets): + if i > 0: + message += " Or\n" + if len(config.credential_sets) > 1: + message += f" ({cred_set.label}):\n" + for var in cred_set.env_vars: + message += f" export {var}=\n" + message += "\n" + + message += ( + "Example for ACI:\n" + " export ACI_URL=https://apic.example.com\n" + " export ACI_USERNAME=admin\n" + " export ACI_PASSWORD=yourpassword\n\n" + "Note: Set credentials for only ONE controller type at a time." + ) + + return message diff --git a/nac_test/cli/validators/controller_auth.py b/nac_test/core/controller_auth.py similarity index 79% rename from nac_test/cli/validators/controller_auth.py rename to nac_test/core/controller_auth.py index d050ebe4..a93ccf05 100644 --- a/nac_test/cli/validators/controller_auth.py +++ b/nac_test/core/controller_auth.py @@ -1,37 +1,33 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2025 Daniel Schmidt -"""Pre-flight controller authentication validator. +"""Pre-flight controller authentication check. -This module provides a pre-flight authentication check that validates controller -credentials before any test execution begins. It uses the same auth implementations -from nac-test-pyats-common that PyATS tests use, ensuring consistent behavior. +Validates controller credentials before test execution by attempting the same +authentication that PyATS tests use. On success the token/session is cached in +AuthCache so the first real test gets a cache hit. -Benefits: -- Fails fast with clear error message instead of N identical auth failures -- Populates the AuthCache, so first real test gets a cache hit -- Works for both PyATS and Robot Framework execution modes - -The pre-flight check happens at the CLI layer before either test framework is -invoked, providing immediate feedback for credential and connectivity issues. +This module lives in ``core/`` because authentication reachability is part of +the controller resolution domain, not a CLI concern. """ import logging -import os import sys from collections.abc import Callable from dataclasses import dataclass from typing import Any +from nac_test.core.auth_cache import AuthCache +from nac_test.core.controller import ( + CONTROLLER_REGISTRY, + get_controller_url, + get_display_name, +) from nac_test.core.error_classification import ( AuthOutcome, classify_auth_error, extract_http_status_code, ) -from nac_test.core.types import ControllerTypeKey -from nac_test.pyats_core.common.auth_cache import AuthCache - -# Import CONTROLLER_REGISTRY from centralized location -from nac_test.utils.controller import CONTROLLER_REGISTRY, get_display_name +from nac_test.core.types import ControllerContext, ControllerTypeKey logger = logging.getLogger(__name__) @@ -58,22 +54,6 @@ class AuthCheckResult: status_code: int | None = None -def _get_controller_url(controller_type: str) -> str: - """Get the controller URL from environment variables. - - Args: - controller_type: The detected controller type. - - Returns: - The controller URL, or empty string if not found. - """ - config = CONTROLLER_REGISTRY.get(controller_type) - if config is None: - return "" - url = os.environ.get(config.url_env_var, "") - return url.rstrip("/") if url else "" - - def _get_auth_callable(controller_type: str) -> Callable[[], Any] | None: """Get the auth function for the given controller type. @@ -117,7 +97,7 @@ def _get_auth_callable(controller_type: str) -> Callable[[], Any] | None: return None -def preflight_auth_check(controller_type: ControllerTypeKey) -> AuthCheckResult: +def preflight_auth_check(ctx: ControllerContext) -> AuthCheckResult: """Attempt authentication to the detected controller before tests run. Uses the same auth implementations from nac-test-pyats-common that @@ -130,12 +110,19 @@ def preflight_auth_check(controller_type: ControllerTypeKey) -> AuthCheckResult: - If environment variables missing: returns success (let the actual auth fail later) Args: - controller_type: Detected controller type (e.g., "ACI", "SDWAN", "CC"). + ctx: Resolved controller context from ``resolve_controller()``. Returns: AuthCheckResult with success/failure status and actionable detail. """ - controller_url = _get_controller_url(controller_type) + controller_type = ctx.controller_type + try: + controller_url = get_controller_url(controller_type) + except KeyError: + controller_url = "" + # Auth adapters strip trailing "/" before caching (cache key normalization). + # Match that here so AuthCache.invalidate() finds the right entry. + cache_url = controller_url.rstrip("/") display_name = get_display_name(controller_type) # Get the auth callable for this controller type @@ -158,13 +145,13 @@ def preflight_auth_check(controller_type: ControllerTypeKey) -> AuthCheckResult: # Invalidate any stale cached token so we validate the current credentials. # Best-effort: a failure here must never block test execution. config = CONTROLLER_REGISTRY.get(controller_type) - if config is not None and config.cache_key is not None and controller_url: + if config is not None and config.cache_key is not None and cache_url: try: logger.debug( "Invalidating auth cache for %s before pre-flight check", config.cache_key, ) - AuthCache.invalidate(config.cache_key, controller_url) + AuthCache.invalidate(config.cache_key, cache_url) except Exception as e: logger.debug("Cache invalidation failed (non-fatal): %s", e) diff --git a/nac_test/core/reporting/combined_generator.py b/nac_test/core/reporting/combined_generator.py index e84555c2..eaf7519e 100644 --- a/nac_test/core/reporting/combined_generator.py +++ b/nac_test/core/reporting/combined_generator.py @@ -23,13 +23,18 @@ ROBOT_RESULTS_DIRNAME, SUMMARY_REPORT_FILENAME, ) +from nac_test.core.controller import ( + CONTROLLER_REGISTRY, + get_credential_vars, + get_display_name, + get_env_var_prefix, +) from nac_test.core.types import ( CombinedResults, ControllerTypeKey, PreFlightFailure, ) from nac_test.pyats_core.reporting.templates import TEMPLATES_DIR, get_jinja_environment -from nac_test.utils.controller import get_display_name, get_env_var_prefix from nac_test.utils.url import extract_host logger = logging.getLogger(__name__) @@ -120,6 +125,26 @@ def _get_curl_example(controller_type: ControllerTypeKey, controller_url: str) - return f"{controller_url}{template.endpoint} \\\n {template.options}" +def _build_controller_credential_vars( + controller_type: str | None, +) -> list[dict[str, str]]: + """Build credential variable list for a specific controller type. + + Delegates to :func:`~nac_test.core.controller.get_credential_vars` (SSOT) + and reformats as template-friendly dicts. + + Returns: + List of dicts with keys ``var`` and ``kind``. + """ + if not controller_type: + return [] + try: + cred_vars = get_credential_vars(controller_type) + except KeyError: + return [] + return [{"var": var, "kind": kind} for kind, var in cred_vars] + + class CombinedReportGenerator: """Generates combined dashboard across all test frameworks. @@ -303,6 +328,12 @@ def _generate_pre_flight_failure_report( ) timestamp = datetime.now().strftime(REPORT_TIMESTAMP_FORMAT) + # Build remediation data from CONTROLLER_REGISTRY + controller_prefixes = "|".join(CONTROLLER_REGISTRY.keys()) + controller_credential_vars = _build_controller_credential_vars( + failure.controller_type + ) + template = self.env.get_template("auth_failure/report.html.j2") html_content = template.render( failure_type=failure.failure_type, @@ -315,6 +346,8 @@ def _generate_pre_flight_failure_report( host=host, curl_example=curl_example, timestamp=timestamp, + controller_prefixes=controller_prefixes, + controller_credential_vars=controller_credential_vars, ) failure_report_path.write_text(html_content, encoding="utf-8") diff --git a/nac_test/core/types.py b/nac_test/core/types.py index 047357e6..75344110 100644 --- a/nac_test/core/types.py +++ b/nac_test/core/types.py @@ -3,9 +3,10 @@ """Core types for nac-test orchestration.""" +import json from dataclasses import dataclass from enum import Enum -from typing import Any, Literal +from typing import Any, Literal, get_args from nac_test.core.constants import ( EXIT_DATA_ERROR, @@ -16,9 +17,84 @@ ) # Type alias for supported controller type keys. -# Matches the keys of CONTROLLER_REGISTRY in nac_test.utils.controller. +# Matches the keys of CONTROLLER_REGISTRY in nac_test.core.controller. ControllerTypeKey = Literal["ACI", "SDWAN", "CC", "MERAKI", "FMC", "ISE", "IOSXE"] +# Type alias for the semantic kind of a credential value. +# Matches the keys used in CredentialSet.fields in nac_test.core.controller. +CredentialKind = Literal["url", "username", "password", "token"] + + +class AuthMethod(str, Enum): + """Authentication mechanism for a controller credential set. + + Consumed by auth adapters in nac-test-pyats-common to branch on + token vs. session authentication. + """ + + SESSION = "session" + TOKEN = "token" # nosec B105 — not a password, enum value for auth method selection + + +@dataclass(frozen=True) +class ControllerContext: + """Resolved controller selection identity passed from orchestrator to subprocess. + + Represents controller *identity* (type + auth method), **not** connection + state. Credentials are resolved separately from environment variables via + ``get_connection_params()`` in ``nac_test.core.controller``. + + Produced once by ``resolve_controller()`` and threaded through the + orchestration chain. Serialized to ``NAC_TEST_CONTROLLER_CONTEXT`` + for subprocess transport (follows the existing ``DEVICE_INFO`` pattern). + + Attributes: + controller_type: Detected controller key (e.g. ``"ACI"``, ``"SDWAN"``). + auth_method: Authentication mechanism selected during resolution + (e.g. ``"token"``, ``"session"``). Consumed by auth adapters in + *nac-test-pyats-common* to branch on token vs. session auth. + """ + + controller_type: ControllerTypeKey + auth_method: AuthMethod + + def to_json(self) -> str: + """Serialize for ``NAC_TEST_CONTROLLER_CONTEXT`` env var.""" + return json.dumps( + {"controller_type": self.controller_type, "auth_method": self.auth_method} + ) + + @classmethod + def from_json(cls, raw: str) -> "ControllerContext": + """Deserialize from ``NAC_TEST_CONTROLLER_CONTEXT`` env var. + + Unknown keys are silently ignored so new fields can be added + without breaking older consumers. + + Raises: + ValueError: If the raw string is not valid JSON. + KeyError: If required fields are missing. + """ + try: + data = json.loads(raw) + except (json.JSONDecodeError, TypeError) as e: + raise ValueError(f"Invalid JSON in controller context: {e}") from e + # Validate controller_type against known values + ct = data["controller_type"] + if ct not in get_args(ControllerTypeKey): + raise ValueError( + f"Unknown controller_type {ct!r}; " + f"expected one of {get_args(ControllerTypeKey)}" + ) + try: + auth = AuthMethod(data["auth_method"]) + except ValueError as e: + raise ValueError(f"Invalid auth_method {data['auth_method']!r}: {e}") from e + return cls( + controller_type=ct, + auth_method=auth, + ) + @dataclass(frozen=True) class ValidatedRobotArgs: diff --git a/nac_test/pyats_core/common/auth_cache.py b/nac_test/pyats_core/common/auth_cache.py index c66044e1..dd29e56f 100644 --- a/nac_test/pyats_core/common/auth_cache.py +++ b/nac_test/pyats_core/common/auth_cache.py @@ -1,219 +1,12 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2025 Daniel Schmidt -"""Generic file-based authentication token caching for parallel processes.""" +"""Backward-compatible re-export — AuthCache moved to nac_test.core.auth_cache. -import hashlib -import json -import logging -import time -from collections.abc import Callable -from pathlib import Path -from typing import Any +This shim exists for nac-test-pyats-common's main branch which still +imports from this path. Remove once all consumers use the new path. +""" -from filelock import FileLock +from nac_test.core.auth_cache import AuthCache -from nac_test.pyats_core.constants import AUTH_CACHE_DIR - -logger = logging.getLogger(__name__) - - -class AuthCache: - """Generic file-based auth token caching across parallel processes - - This is controller-agnostic - each architecture provides their own auth function - """ - - @classmethod - def _cache_auth_data( - cls, - controller_type: str, - url: str, - auth_func: Callable[[], tuple[Any, int]], - extract_token: bool = False, - ) -> Any: - """Internal method for caching auth data with file-based locking. - - Args: - controller_type: Type of controller - url: Controller URL - auth_func: Function that returns (auth_data, expires_in_seconds) - extract_token: If True, expects auth_data to be a string token. - If False, expects a dict. - - Returns: - Either a token string or auth dict based on extract_token flag - """ - cache_dir = Path(AUTH_CACHE_DIR) - cache_dir.mkdir(exist_ok=True) - - url_hash = hashlib.md5(url.encode(), usedforsecurity=False).hexdigest() - cache_file = cache_dir / f"{controller_type}_{url_hash}.json" - lock_file = cache_dir / f"{controller_type}_{url_hash}.lock" - - with FileLock(str(lock_file)): - # Check if valid cached data exists - if cache_file.exists(): - try: - with open(cache_file, encoding="utf-8") as f: - data = json.load(f) - if time.time() < data["expires_at"]: - # Return based on what type of data we're working with - if extract_token: - return str(data["token"]) - else: - # Return the auth_data dict (minus expires_at) - return { - k: v for k, v in data.items() if k != "expires_at" - } - except json.JSONDecodeError as e: - logger.warning( - "Invalid JSON in cache file %s, will recreate: %s", - cache_file, - e, - ) - except KeyError as e: - logger.warning( - "Missing key in cache file %s, will recreate: %s", - cache_file, - e, - ) - except TypeError as e: - logger.warning( - "Type error reading cache file %s, will recreate: %s", - cache_file, - e, - ) - - # Get new auth data - auth_data, expires_in = auth_func() - - # Prepare cache data - cache_data: dict[str, Any] = {"expires_at": time.time() + expires_in - 60} - - if extract_token: - # Legacy token mode - auth_data is a string - cache_data["token"] = str(auth_data) - result: Any = str(auth_data) - else: - # Generic dict mode - merge auth_data dict - auth_dict = ( - dict(auth_data) if not isinstance(auth_data, dict) else auth_data - ) - cache_data.update(auth_dict) - result = auth_dict - - # Cache it - with open(cache_file, "w", encoding="utf-8") as f: - json.dump(cache_data, f) - - cache_file.chmod(0o600) - return result - - @classmethod - def invalidate(cls, controller_type: str, url: str) -> None: - """Remove the cached auth data for a given controller type and URL. - - This is a best-effort operation: if the cache file does not exist or - cannot be deleted, a debug message is logged and no exception is raised. - Both the cache file and its associated lock file are cleaned up. - - Args: - controller_type: Type of controller (e.g., "ACI", "SDWAN_MANAGER", "CC"). - url: Controller URL used to derive the cache file path. - """ - cache_dir = Path(AUTH_CACHE_DIR) - url_hash = hashlib.md5(url.encode(), usedforsecurity=False).hexdigest() - cache_file = cache_dir / f"{controller_type}_{url_hash}.json" - lock_file = cache_dir / f"{controller_type}_{url_hash}.lock" - - try: - with FileLock(str(lock_file)): - if cache_file.exists(): - cache_file.unlink() - logger.debug( - "Invalidated auth cache for %s at %s", controller_type, url - ) - else: - logger.debug( - "No auth cache to invalidate for %s at %s", - controller_type, - url, - ) - except Exception as e: - logger.debug( - "Best-effort cache invalidation failed for %s at %s: %s", - controller_type, - url, - e, - ) - return - - # Clean up the lock file after releasing the lock - try: - if lock_file.exists(): - lock_file.unlink() - except Exception as e: - logger.debug("Could not remove lock file %s: %s", lock_file, e) - - @classmethod - def get_or_create( - cls, - controller_type: str, - url: str, - auth_func: Callable[[], tuple[dict[str, Any], int]], - ) -> dict[str, Any]: - """Get existing auth data dict or create new one with file-based locking. - - Generic method for caching any JSON-serializable dict. - - Args: - controller_type: Type of controller (SDWAN_MANAGER, CC, etc) - url: Controller URL - auth_func: Function that returns (auth_dict, expires_in_seconds) - - Returns: - Dict containing authentication data (without expires_at) - """ - result = cls._cache_auth_data( - controller_type=controller_type, - url=url, - auth_func=auth_func, - extract_token=False, - ) - # Type narrowing for mypy - we know it's a dict when extract_token=False - assert isinstance(result, dict) - return result - - @classmethod - def get_or_create_token( - cls, - controller_type: str, - url: str, - username: str, - password: str, - auth_func: Callable[[str, str, str], tuple[str, int]], - ) -> str: - """Get existing token or create new one with file-based locking - - Args: - controller_type: Type of controller (APIC, CC, etc) - url: Controller URL - username: Username for authentication - password: Password for authentication - auth_func: Architecture-specific auth function that returns (token, expires_in_seconds) - """ - - # Create a wrapper function that captures the username/password - def wrapped_auth_func() -> tuple[str, int]: - return auth_func(url, username, password) - - result = cls._cache_auth_data( - controller_type=controller_type, - url=url, - auth_func=wrapped_auth_func, - extract_token=True, - ) - # Type narrowing for mypy - we know it's a str when extract_token=True - assert isinstance(result, str) - return result +__all__ = ["AuthCache"] diff --git a/nac_test/pyats_core/common/base_test.py b/nac_test/pyats_core/common/base_test.py index c5080e44..5dde72e1 100644 --- a/nac_test/pyats_core/common/base_test.py +++ b/nac_test/pyats_core/common/base_test.py @@ -29,6 +29,12 @@ FILE_TIMESTAMP_FORMAT, PYATS_RESULTS_DIRNAME, ) +from nac_test.core.controller import ( + get_connection_params, + get_controller_context, + get_controller_url, + get_defaults_prefix, +) from nac_test.pyats_core.common.connection_pool import ConnectionPool from nac_test.pyats_core.common.defaults_resolver import ( resolve_default_value, @@ -44,11 +50,6 @@ from nac_test.pyats_core.reporting.step_interceptor import StepInterceptor from nac_test.pyats_core.reporting.types import ResultStatus from nac_test.utils import sanitize_hostname -from nac_test.utils.controller import ( - detect_controller_type, - get_controller_url, - get_defaults_prefix, -) from nac_test.utils.formatting import format_file_timestamp_ms from nac_test.utils.yaml import safe_load @@ -70,6 +71,11 @@ class NACTestBase(aetest.Testcase): # type: ignore[misc] # Test metadata class variables (enforced in subclasses) TEST_TYPE_NAME: str | None = None + # Subclass-declared guards — validated in setup() when not None. + # Subclasses set these as class attributes; the base class enforces them. + EXPECTED_CONTROLLER_TYPE: str | None = None + SUPPORTED_AUTH_METHODS: set[str] | None = None + # Explicit attribute declarations (avoids hasattr() checks) batching_reporter: BatchingReporter | None = None step_interceptor: StepInterceptor | None = None @@ -165,21 +171,60 @@ def setup(self) -> None: # Load merged data model created by nac-test self.data_model = self.load_data_model() - # Get controller details from environment - # Note: Environment validation happens in orchestrator pre-flight check - # Detect controller type based on environment variables + # Get controller context from environment + # In normal operation, CombinedOrchestrator resolves the controller and + # passes it via NAC_TEST_CONTROLLER_CONTEXT env var. The accessor + # get_controller_context() reads this, with a fallback to env var scan + # for direct pyats invocation or legacy compatibility. try: - self.controller_type = detect_controller_type() - except ValueError as e: - # Log error and re-raise to fail the test setup + ctx = get_controller_context() + except (ValueError, KeyError) as e: self.logger.error(f"Controller detection failed: {e}") raise + self.controller_type = ctx.controller_type + + # Validate controller type if subclass declares an expectation + if ( + self.EXPECTED_CONTROLLER_TYPE is not None + and self.controller_type != self.EXPECTED_CONTROLLER_TYPE + ): + self.failed( + f"This test requires " + f"controller_type={self.EXPECTED_CONTROLLER_TYPE}, " + f"but resolved controller_type={self.controller_type!r}" + ) + return self.controller_url = get_controller_url(self.controller_type) - # USERNAME and PASSWORD are optional for some controller types (e.g., IOSXE) - # D2D tests use device-specific credentials from inventory, not controller credentials - self.username = os.environ.get(f"{self.controller_type}_USERNAME") - self.password = os.environ.get(f"{self.controller_type}_PASSWORD") + + # Generic connection params (url/username/password/token, keyed by kind) + # for the resolved auth method. If controller resolution succeeded, + # connection params must also resolve — failure here indicates a real + # misconfiguration, not a soft-skip scenario. + self.auth_method = ctx.auth_method + + # Validate auth method if subclass declares supported methods + if ( + self.SUPPORTED_AUTH_METHODS is not None + and self.auth_method not in self.SUPPORTED_AUTH_METHODS + ): + self.failed( + f"{self.EXPECTED_CONTROLLER_TYPE or type(self).__name__} " + f"adapter supports auth_methods " + f"{self.SUPPORTED_AUTH_METHODS}, " + f"got {self.auth_method!r}" + ) + return + + self.connection_params = get_connection_params( + self.controller_type, ctx.auth_method + ) + + # USERNAME and PASSWORD are optional for some controller types/credential + # sets (e.g., IOSXE has none, SDWAN's token credential set has neither). + # D2D tests use device-specific credentials from inventory, not these. + self.username = self.connection_params.get("username") + self.password = self.connection_params.get("password") # Connection pool is shared within process (for API tests) self.pool = ConnectionPool() @@ -866,17 +911,6 @@ async def api_call_with_retry( """ return await SmartRetry.execute(func, *args, **kwargs) - def get_connection_params(self) -> dict[str, Any]: - """Get connection parameters for the specific architecture. - - Must be implemented by subclasses to return architecture-specific - connection details. - """ - raise NotImplementedError( - f"{self.__class__.__name__} must implement get_connection_params() to return " - f"architecture-specific connection details." - ) - def wrap_client_for_tracking( self, client: Any, device_name: str = "Controller" ) -> Any: diff --git a/nac_test/pyats_core/constants.py b/nac_test/pyats_core/constants.py index 586fc558..1cb55869 100644 --- a/nac_test/pyats_core/constants.py +++ b/nac_test/pyats_core/constants.py @@ -4,7 +4,6 @@ """PyATS-specific constants and configuration.""" import os -import tempfile from nac_test._env import get_bool_env, get_positive_numeric_env from nac_test.core.constants import ( @@ -41,9 +40,6 @@ "NAC_TEST_DEVICE_EXECUTE_TIMEOUT", 120, int ) -# PyATS-specific file paths -AUTH_CACHE_DIR: str = os.path.join(tempfile.gettempdir(), "nac-test-auth-cache") - # PyATS config files written to output directory during test execution PYATS_PLUGIN_CONFIG_FILENAME: str = ".pyats_plugin.yaml" PYATS_CONFIG_FILENAME: str = ".pyats.conf" @@ -130,6 +126,10 @@ # relative (dot-notation) test names. ENV_TEST_DIR: str = "NAC_TEST_TEST_DIR" +# Valid test types for PyATS test classification +# Used by discovery and cleanup modules +VALID_TEST_TYPES: frozenset[str] = frozenset({"api", "d2d"}) + # Re-export all constants for backward compatibility __all__ = [ # From core @@ -149,7 +149,6 @@ "MEMORY_PER_WORKER_GB", "DEFAULT_CPU_MULTIPLIER", "LOAD_AVERAGE_THRESHOLD", - "AUTH_CACHE_DIR", "PYATS_PLUGIN_CONFIG_FILENAME", "PYATS_CONFIG_FILENAME", "PYATS_POST_DISCONNECT_WAIT_SECONDS", @@ -178,4 +177,6 @@ "OVERFLOW_DIR_OVERRIDE", # Environment variable name "ENV_TEST_DIR", + # Test type classification + "VALID_TEST_TYPES", ] diff --git a/nac_test/pyats_core/discovery/test_type_resolver.py b/nac_test/pyats_core/discovery/test_type_resolver.py index 41d370e2..c0448f37 100644 --- a/nac_test/pyats_core/discovery/test_type_resolver.py +++ b/nac_test/pyats_core/discovery/test_type_resolver.py @@ -93,9 +93,6 @@ class inheritance. logger = logging.getLogger(__name__) -# Module-level constants -VALID_TEST_TYPES: Final[set[TestType]] = {"api", "d2d"} - # Base class to test type mapping # This dictionary maps known PyATS test base class names to their test types BASE_CLASS_MAPPING: Final[dict[str, TestType]] = { diff --git a/nac_test/pyats_core/orchestrator.py b/nac_test/pyats_core/orchestrator.py index 6465d5be..4be99cf1 100644 --- a/nac_test/pyats_core/orchestrator.py +++ b/nac_test/pyats_core/orchestrator.py @@ -16,12 +16,14 @@ from nac_test.core.constants import ( DEBUG_MODE, DRY_RUN_REASON, + ENV_CONTROLLER_CONTEXT, EXIT_ERROR, PYATS_RESULTS_DIRNAME, SUMMARY_REPORT_FILENAME, SUMMARY_SEPARATOR_WIDTH, ) -from nac_test.core.types import PyATSResults, TestResults +from nac_test.core.controller import ResolutionError, resolve_controller +from nac_test.core.types import ControllerContext, PyATSResults, TestResults from nac_test.data_merger import DataMerger from nac_test.pyats_core.broker.connection_broker import ConnectionBroker from nac_test.pyats_core.constants import ( @@ -49,8 +51,6 @@ cleanup_old_test_outputs, cleanup_pyats_runtime, ) -from nac_test.utils.controller import detect_controller_type -from nac_test.utils.environment import EnvironmentValidator from nac_test.utils.formatting import format_duration from nac_test.utils.logging import DEFAULT_LOGLEVEL, LogLevel from nac_test.utils.system_resources import SystemResourceCalculator @@ -69,7 +69,7 @@ def __init__( output_dir: Path, minimal_reports: bool = False, custom_testbed_path: Path | None = None, - controller_type: str | None = None, + controller_context: ControllerContext | None = None, dry_run: bool = False, verbose: bool = False, loglevel: LogLevel = DEFAULT_LOGLEVEL, @@ -84,8 +84,8 @@ def __init__( output_dir: Base output directory (orchestrator creates pyats_results subdirectory) minimal_reports: Only include command outputs for failed/errored tests in reports custom_testbed_path: Path to custom PyATS testbed YAML for device overrides - controller_type: The detected controller type (e.g., "ACI", "SDWAN", "CC"). - If not provided, will be detected automatically. + controller_context: Resolved controller context from the parent + orchestrator. If not provided, will be resolved automatically. dry_run: If True, validate test structure without executing tests verbose: Enable verbose mode - verbose output loglevel: Log level for PyATS output filtering @@ -121,20 +121,20 @@ def __init__( # Track test status (initialized to None, populated during test execution) self.test_status: dict[str, Any] | None = None - # Use provided controller type or detect it - if controller_type: - # Controller type provided by caller (e.g., CombinedOrchestrator) - self.controller_type = controller_type - logger.info(f"Using provided controller type: {self.controller_type}") + # Use provided controller context or resolve it + if controller_context: + self.controller_context = controller_context + self.controller_type = controller_context.controller_type + logger.info("Using provided controller context: %s", self.controller_type) else: - # Fallback to auto-detection for standalone usage + # Fallback to auto-resolution for standalone usage try: - self.controller_type = detect_controller_type() - logger.info(f"Controller type detected: {self.controller_type}") - except ValueError as e: - # Exit gracefully if controller detection fails - logger.error(f"Controller detection failed: {e}") - print(terminal.error(f"Controller detection failed:\n{e}")) + self.controller_context = resolve_controller() + self.controller_type = self.controller_context.controller_type + logger.info("Controller type resolved: %s", self.controller_type) + except ResolutionError as e: + logger.error("Controller resolution failed: %s", e) + print(terminal.error(f"Controller resolution failed:\n{e}")) sys.exit(EXIT_ERROR) # Calculate max workers based on system resources @@ -278,6 +278,8 @@ async def _execute_api_tests_standard(self, test_files: list[Path]) -> Path | No env["NAC_TEST_TYPE"] = "api" # Pass test_dir so plugin can compute relative test names env[ENV_TEST_DIR] = str(self.test_dir) + # Serialize controller context for subprocess transport + env[ENV_CONTROLLER_CONTEXT] = self.controller_context.to_json() # Execute and return the archive path assert self.subprocess_runner is not None # Should be initialized by now @@ -364,6 +366,8 @@ async def _execute_ssh_tests_device_centric( # Set environment variable for test subprocesses to find broker os.environ["NAC_TEST_BROKER_SOCKET"] = str(broker.socket_path) + # Serialize controller context for subprocess transport + os.environ[ENV_CONTROLLER_CONTEXT] = self.controller_context.to_json() # Execute device tests with broker running return await self._execute_device_tests_with_broker(test_files, devices) @@ -374,8 +378,9 @@ async def _execute_ssh_tests_device_centric( ) return None finally: - # Clean up environment variable + # Clean up environment variables os.environ.pop("NAC_TEST_BROKER_SOCKET", None) + os.environ.pop(ENV_CONTROLLER_CONTEXT, None) async def _execute_device_tests_with_broker( self, test_files: list[Path], devices: list[dict[str, Any]] @@ -469,18 +474,6 @@ async def _execute_device_tests_with_broker( logger.warning("No device archives were generated") return None - def validate_environment(self) -> None: - """Pre-flight check: Validate required environment variables before running tests. - - This ensures we fail fast with clear error messages rather than starting - PyATS only to have all tests fail due to missing configuration. - - Raises: - SystemExit: If required environment variables are missing - """ - # Use the detected controller type - EnvironmentValidator.validate_controller_env(self.controller_type) - def _extract_pyats_stats( self, pyats_stats: dict[str, dict[str, Any]] ) -> PyATSResults: @@ -571,9 +564,6 @@ async def _run_tests_async(self) -> PyATSResults: if os.environ.get("CI"): cleanup_old_test_outputs(self.output_dir, days=3) - # Pre-flight check and setup - self.validate_environment() - # Note: Merged data file created by main.py (single source of truth) discovery_result = self.test_discovery.discover_pyats_tests( diff --git a/nac_test/pyats_core/reporting/templates/auth_failure/report.html.j2 b/nac_test/pyats_core/reporting/templates/auth_failure/report.html.j2 index 933618e0..4bd8850e 100644 --- a/nac_test/pyats_core/reporting/templates/auth_failure/report.html.j2 +++ b/nac_test/pyats_core/reporting/templates/auth_failure/report.html.j2 @@ -390,26 +390,10 @@
    {% if failure_type.is_detection %}
  1. - Set controller environment variables for your architecture: -
    - # For ACI (APIC):
    - $ export ACI_URL=https://apic.example.com
    - $ export ACI_USERNAME=admin
    - $ export ACI_PASSWORD=your-password
    -
    - # For SD-WAN Manager:
    - $ export SDWAN_URL=https://sdwan.example.com
    - $ export SDWAN_USERNAME=admin
    - $ export SDWAN_PASSWORD=your-password
    -
    - # For Catalyst Center:
    - $ export CC_URL=https://catalyst.example.com
    - $ export CC_USERNAME=admin
    - $ export CC_PASSWORD=your-password -
    + Set controller environment variables for your chosen architecture — see the required environment variables listed in the error details above.
  2. - Verify environment variables are exported — use env | grep -E "^(ACI|SDWAN|CC)_" to check. + Verify environment variables are exported — use env | grep -E "^({{ controller_prefixes }})_" to check.
  3. Check shell context — if running via CI/CD, ensure variables are passed to the job environment. @@ -446,12 +430,14 @@ Verify your credentials are correct:
    # Check current values
    - $ echo ${{ env_var_prefix }}_USERNAME
    - $ echo ${{ env_var_prefix }}_PASSWORD
    + {% for cred in controller_credential_vars %} + $ echo ${{ cred.var }}
    + {% endfor %}
    # Set correct values
    - $ export {{ env_var_prefix }}_USERNAME=<your-username>
    - $ export {{ env_var_prefix }}_PASSWORD=<your-password> + {% for cred in controller_credential_vars %} + $ export {{ cred.var }}=<{{ cred.kind }}>
    + {% endfor %}
  4. {% if is_403 %} diff --git a/nac_test/utils/__init__.py b/nac_test/utils/__init__.py index 85d0f25d..b98ef712 100644 --- a/nac_test/utils/__init__.py +++ b/nac_test/utils/__init__.py @@ -9,12 +9,10 @@ cleanup_pyats_runtime, cleanup_stale_test_artifacts, ) -from nac_test.utils.controller import detect_controller_type from nac_test.utils.device_validation import ( REQUIRED_DEVICE_FIELDS, validate_device_inventory, ) -from nac_test.utils.environment import EnvironmentValidator from nac_test.utils.file_discovery import find_data_file from nac_test.utils.logging import LogLevel, configure_logging from nac_test.utils.strings import sanitize_hostname @@ -24,7 +22,6 @@ __all__ = [ "terminal", "SystemResourceCalculator", - "EnvironmentValidator", "cleanup_pyats_runtime", "cleanup_old_test_outputs", "cleanup_stale_test_artifacts", @@ -37,8 +34,6 @@ "validate_device_inventory", # File discovery utilities (SSH/D2D architecture) "find_data_file", - # Controller detection utilities (SSH/D2D architecture) - "detect_controller_type", # String utilities "sanitize_hostname", ] diff --git a/nac_test/utils/cleanup.py b/nac_test/utils/cleanup.py index bddef999..78ca2f3c 100644 --- a/nac_test/utils/cleanup.py +++ b/nac_test/utils/cleanup.py @@ -14,7 +14,7 @@ from typing import Any from nac_test.core.constants import DEBUG_MODE, IS_WINDOWS -from nac_test.pyats_core.discovery.test_type_resolver import VALID_TEST_TYPES +from nac_test.pyats_core.constants import VALID_TEST_TYPES logger = logging.getLogger(__name__) diff --git a/nac_test/utils/controller.py b/nac_test/utils/controller.py index 5878d788..9611d326 100644 --- a/nac_test/utils/controller.py +++ b/nac_test/utils/controller.py @@ -1,568 +1,18 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2025 Daniel Schmidt -"""Controller type detection utilities for NAC test framework. +"""Bridge-release compatibility shim. -This module provides utilities for detecting which network controller type (architecture) -is being targeted based on environment variables. Controller credentials are required for -ALL test types (both API and D2D tests) as they determine the architecture context. +Re-exports only the controller symbols actually used by ``nac-test-pyats-common``: +- detect_controller_type (iosxe/test_base.py) +- get_matched_credential_set (sdwan/auth.py) -The detection logic ensures exactly one controller type is configured at a time to prevent -ambiguous test execution contexts. - -The module also provides a mapping from controller types to their defaults block prefixes, -enabling automatic defaults resolution without per-architecture configuration. For example, -when ACI_URL is detected, the framework automatically knows to look for defaults.apic in -the merged NAC data model. +This shim exists so that ``nac-test-pyats-common`` continues to work during the +transition window. It will be removed after all consumers have migrated to +``nac_test.core.controller`` (Phase 3 of the controller-resolution refactor). """ -import logging -import os -from collections.abc import Sequence -from dataclasses import dataclass -from typing import cast - -from nac_test.core.types import ControllerTypeKey - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class CredentialSet: - """A single credential combination that can authenticate to a controller. - - Each set is self-contained: if ALL env_vars are present and non-empty, - the controller is considered fully configured. When a controller has - multiple CredentialSets, the first satisfied set wins (order matters). - - Attributes: - env_vars: Environment variable names required for this credential method. - label: Human-readable label for error messages (e.g., "API Token (20.18+)"). - auth_method: Identifier consumed by auth adapters in nac-test-pyats-common - to select the authentication mechanism (e.g., "token", "session"). - """ - - env_vars: tuple[str, ...] - label: str - auth_method: str = "session" - - -@dataclass(frozen=True) -class ControllerConfig: - """Configuration metadata for a supported controller type. - - Attributes: - display_name: User-facing name (e.g., "APIC", "Catalyst Center"). - url_env_var: Environment variable name for the controller URL. - env_var_prefix: Prefix for credential env vars (e.g., "ACI" → ACI_USERNAME). - credential_sets: Ordered list of credential combinations. The first set - whose env_vars are all present and non-empty wins. Every controller - must have at least one CredentialSet. - defaults_prefix: JMESPath prefix for the defaults block in NAC data models - (e.g., "defaults.apic", "defaults.sdwan"). - cache_key: The controller_type string passed to AuthCache by the auth adapter. - None for controllers that don't have an auth adapter in nac-test-pyats-common. - """ - - display_name: str - url_env_var: str - env_var_prefix: str - credential_sets: tuple[CredentialSet, ...] - defaults_prefix: str - cache_key: str | None = None - - -# Single source of truth for all controller configurations -# Replaces the registry from controller_auth.py -CONTROLLER_REGISTRY: dict[str, ControllerConfig] = { - "ACI": ControllerConfig( - display_name="APIC", - url_env_var="ACI_URL", - env_var_prefix="ACI", - credential_sets=( - CredentialSet( - env_vars=("ACI_URL", "ACI_USERNAME", "ACI_PASSWORD"), - label="Username/Password", - ), - ), - defaults_prefix="defaults.apic", - cache_key="ACI", - ), - "SDWAN": ControllerConfig( - display_name="SDWAN Manager", - url_env_var="SDWAN_URL", - env_var_prefix="SDWAN", - credential_sets=( - CredentialSet( - env_vars=("SDWAN_URL", "SDWAN_API_TOKEN"), - label="API Token (20.18+)", - auth_method="token", - ), - CredentialSet( - env_vars=("SDWAN_URL", "SDWAN_USERNAME", "SDWAN_PASSWORD"), - label="Username/Password", - ), - ), - defaults_prefix="defaults.sdwan", - cache_key="SDWAN_MANAGER", - ), - "CC": ControllerConfig( - display_name="Catalyst Center", - url_env_var="CC_URL", - env_var_prefix="CC", - credential_sets=( - CredentialSet( - env_vars=("CC_URL", "CC_USERNAME", "CC_PASSWORD"), - label="Username/Password", - ), - ), - defaults_prefix="defaults.catc", - cache_key="CC", - ), - "MERAKI": ControllerConfig( - display_name="Meraki", - url_env_var="MERAKI_URL", - env_var_prefix="MERAKI", - credential_sets=( - CredentialSet( - env_vars=("MERAKI_URL", "MERAKI_USERNAME", "MERAKI_PASSWORD"), - label="Username/Password", - ), - ), - defaults_prefix="defaults.meraki", - ), - "FMC": ControllerConfig( - display_name="Firepower Management Center", - url_env_var="FMC_URL", - env_var_prefix="FMC", - credential_sets=( - CredentialSet( - env_vars=("FMC_URL", "FMC_USERNAME", "FMC_PASSWORD"), - label="Username/Password", - ), - ), - defaults_prefix="defaults.fmc", - ), - "ISE": ControllerConfig( - display_name="ISE", - url_env_var="ISE_URL", - env_var_prefix="ISE", - credential_sets=( - CredentialSet( - env_vars=("ISE_URL", "ISE_USERNAME", "ISE_PASSWORD"), - label="Username/Password", - ), - ), - defaults_prefix="defaults.ise", - ), - "IOSXE": ControllerConfig( - display_name="IOS XE", - url_env_var="IOSXE_URL", - env_var_prefix="IOSXE", - # Direct device access, no controller credentials required - credential_sets=( - CredentialSet( - env_vars=("IOSXE_URL", "IOSXE_USERNAME", "IOSXE_PASSWORD"), - label="Device URL", - ), - CredentialSet( - env_vars=("IOSXE_HOST", "IOSXE_USERNAME", "IOSXE_PASSWORD"), - label="Device Host", - ), - ), - defaults_prefix="defaults.iosxe", - ), -} - -# Module-level cache for the credential set that was matched during detection. -# Populated by detect_controller_type(), consumed by get_matched_credential_set(). -_matched_credential_sets: dict[str, CredentialSet] = {} - - -def detect_controller_type() -> ControllerTypeKey: - """Detect the controller type based on environment variables. - - This function examines environment variables to determine which network controller - architecture is being targeted. It ensures exactly one controller type has credentials - configured to prevent ambiguous test contexts. - - Controller credentials are required for ALL test types: - - API tests: Use credentials directly for controller authentication - - D2D tests: Use controller type to determine device resolution logic - - Returns: - The detected controller type (e.g., "ACI", "SDWAN", "CC", "MERAKI", "FMC", "ISE"). - - Raises: - ValueError: If no controller credentials are found, multiple controllers are - configured, or credentials are incomplete. - - Example: - >>> os.environ.update({"ACI_URL": "https://apic.local", - ... "ACI_USERNAME": "admin", - ... "ACI_PASSWORD": "pass"}) - >>> controller = detect_controller_type() - >>> print(controller) - "ACI" - """ - logger.debug("Starting controller type detection") - logger.debug(f"Checking for credentials: {list(CONTROLLER_REGISTRY.keys())}") - - complete, partial = _find_credential_sets() - - logger.debug(f"Complete credential sets found: {list(complete.keys())}") - logger.debug(f"Partial credential sets found: {partial}") - - # Check for multiple complete credential sets - if len(complete) > 1: - error_message = _format_multiple_credentials_error(list(complete.keys())) - logger.error( - f"Multiple controller credentials detected: {list(complete.keys())}" - ) - raise ValueError(error_message) - - # Check for no credentials at all - if not complete and not partial: - error_message = _format_no_credentials_error() - logger.error("No controller credentials found in environment") - raise ValueError(error_message) - - # Check for incomplete credentials - if not complete and partial: - error_message = _format_incomplete_credentials_error(partial) - logger.error(f"Incomplete credentials: {partial}") - raise ValueError(error_message) - - # Exactly one complete set found - success - controller_type = next(iter(complete)) - _matched_credential_sets[controller_type] = complete[controller_type] - logger.info( - f"Detected controller type: {controller_type} " - f"(auth_method={complete[controller_type].auth_method})" - ) - return controller_type - - -def _is_env_var_set(var: str) -> bool: - """Check if env var exists and has a non-whitespace value.""" - value = os.environ.get(var) - return bool(value and value.strip()) - - -def _find_credential_sets() -> tuple[ - dict[ControllerTypeKey, CredentialSet], - list[ControllerTypeKey], -]: - """Find complete and partial credential sets in environment. - - For each controller, iterates through its credential_sets in order. The first - set whose env_vars are all present and non-empty marks the controller as - complete. If no set is fully satisfied but at least one variable from any set - is present, the controller is reported as partial. - - Returns: - A tuple containing: - - Dictionary mapping complete controller types to the winning CredentialSet - - List of controller types with partial credentials - """ - complete: dict[ControllerTypeKey, CredentialSet] = {} - partial: list[ControllerTypeKey] = [] - - for controller_type, config in CONTROLLER_REGISTRY.items(): - found_complete = False - has_any_var = False - ct_key = cast(ControllerTypeKey, controller_type) - - for cred_set in config.credential_sets: - all_present = True - - for var in cred_set.env_vars: - if _is_env_var_set(var): - has_any_var = True - logger.debug(f" {controller_type}: Found {var}") - else: - all_present = False - - if all_present: - complete[ct_key] = cred_set - logger.debug(f" {controller_type}: Complete via {cred_set.label}") - found_complete = True - break - - if not found_complete and has_any_var: - partial.append(ct_key) - - return complete, partial - - -def _format_incomplete_credentials_error(partial_controllers: Sequence[str]) -> str: - """Format error message for incomplete controller credentials. - - Creates a detailed error message listing each partially configured - controller and its accepted credential sets, so the user knows - exactly which variables are needed. - - Args: - partial_controllers: List of controller types with partial credentials. - - Returns: - Formatted error message with accepted credential sets. - - Example: - >>> error = _format_incomplete_credentials_error(["SDWAN"]) - >>> print(error) - Incomplete controller credentials detected: - ... - """ - lines_parts: list[str] = [] - for controller in partial_controllers: - config = CONTROLLER_REGISTRY[controller] - set_descriptions = [ - f"{cs.label}: {' + '.join(cs.env_vars)}" for cs in config.credential_sets - ] - line = f"{controller}: incomplete credentials" - line += "\n Accepted credential sets:\n" - line += "\n".join(f" - {desc}" for desc in set_descriptions) - lines_parts.append(line) - lines = "\n".join(f" - {info}" for info in lines_parts) - return ( - f"Incomplete controller credentials detected:\n" - f"{lines}\n\n" - f"Please provide all required variables for one of the " - f"accepted credential sets listed above." - ) - - -def _format_multiple_credentials_error(controllers: list[str]) -> str: - """Format error message for multiple controller credentials. - - Creates a detailed error message with remediation options when multiple - controller types have complete credentials configured. - - Args: - controllers: List of controller types with complete credentials. - - Returns: - Formatted error message with remediation steps. - - Example: - >>> error = _format_multiple_credentials_error(["ACI", "SDWAN"]) - >>> print(error) - Multiple controller credentials detected: ACI, SDWAN - ... - """ - controller_list = ", ".join(controllers) - - message = ( - f"Multiple controller credentials detected: {controller_list}\n\n" - f"The test framework requires exactly one controller type to be configured.\n\n" - f"Remediation options:\n" - f"1. Keep only one controller's credentials and unset the others:\n" - ) - - # Collect all env vars per controller (union of all credential sets) - def _all_env_vars(controller: str) -> list[str]: - config = CONTROLLER_REGISTRY[controller] - seen: set[str] = set() - result: list[str] = [] - for cs in config.credential_sets: - for v in cs.env_vars: - if v not in seen: - seen.add(v) - result.append(v) - return result - - # Add specific unset commands for each controller - for controller in controllers: - other_controllers = [c for c in controllers if c != controller] - vars_to_remove = [] - for other in other_controllers: - vars_to_remove.extend(_all_env_vars(other)) - - unset_command = f" unset {' '.join(vars_to_remove)}" - message += f"\n To use {controller} only:\n{unset_command}\n" - - message += ( - "\n2. Use a separate shell session for each controller type\n" - "\n3. Use environment variable management tools (direnv, dotenv) to switch contexts" - ) - - return message - - -def _format_no_credentials_error() -> str: - """Format error message when no controller credentials are found. - - Creates a detailed error message with setup instructions when no controller - credentials are detected in the environment. - - Returns: - Formatted error message with setup guidance. - - Example: - >>> error = _format_no_credentials_error() - >>> print(error) - No controller credentials found in environment. - ... - """ - message = ( - "No controller credentials found in environment.\n\n" - "Controller credentials are required for ALL test types (API and D2D).\n" - "The framework uses these to determine the architecture context.\n\n" - "Please set environment variables for ONE of the following controller types:\n\n" - ) - - for controller_type, config in CONTROLLER_REGISTRY.items(): - message += f"{controller_type}:\n" - for i, cred_set in enumerate(config.credential_sets): - if i > 0: - message += " Or\n" - if len(config.credential_sets) > 1: - message += f" ({cred_set.label}):\n" - for var in cred_set.env_vars: - message += f" export {var}=\n" - message += "\n" - - message += ( - "Example for ACI:\n" - " export ACI_URL=https://apic.example.com\n" - " export ACI_USERNAME=admin\n" - " export ACI_PASSWORD=yourpassword\n\n" - "Note: Set credentials for only ONE controller type at a time." - ) - - return message - - -def get_display_name(controller_type: str) -> str: - """Get the user-facing display name for a controller type. - - Looks up the display name from CONTROLLER_REGISTRY. If the controller type - is not registered, returns the controller_type string as-is for graceful - degradation. - - Args: - controller_type: The internal controller type key (e.g., "ACI", "SDWAN", "CC"). - - Returns: - The user-facing display name (e.g., "APIC", "SDWAN Manager", "Catalyst Center"), - or the controller_type string if not found in registry. - """ - config = CONTROLLER_REGISTRY.get(controller_type) - return config.display_name if config else controller_type - - -def get_env_var_prefix(controller_type: str) -> str: - """Get the environment variable prefix for a controller type. - - Looks up the env_var_prefix from CONTROLLER_REGISTRY. If the controller type - is not registered, returns the controller_type string as-is for graceful - degradation. - - Args: - controller_type: The internal controller type key (e.g., "ACI", "SDWAN", "CC"). - - Returns: - The environment variable prefix (e.g., "ACI", "SDWAN", "CC"), - or the controller_type string if not found in registry. - """ - config = CONTROLLER_REGISTRY.get(controller_type) - return config.env_var_prefix if config else controller_type - - -def get_defaults_prefix(controller_type: str) -> str: - """Get the JMESPath defaults prefix for a controller type. - - Looks up the defaults_prefix from CONTROLLER_REGISTRY. If the controller type - is not registered, constructs a default prefix of "defaults." - for graceful degradation. - - Args: - controller_type: The internal controller type key (e.g., "ACI", "SDWAN", "CC"). - - Returns: - The JMESPath defaults prefix (e.g., "defaults.apic", "defaults.sdwan"), - or "defaults." if not found in registry. - - Example: - >>> get_defaults_prefix("ACI") - 'defaults.apic' - >>> get_defaults_prefix("SDWAN") - 'defaults.sdwan' - >>> get_defaults_prefix("UNKNOWN") - 'defaults.unknown' - """ - config = CONTROLLER_REGISTRY.get(controller_type) - return config.defaults_prefix if config else f"defaults.{controller_type.lower()}" - - -def get_controller_url(controller_type: str) -> str: - """Get the controller URL from environment variables. - - Iterates through credential sets in order, returning the first env var value - found. This follows the same first-match-wins pattern as _find_credential_sets. - - Args: - controller_type: The internal controller type key (e.g., "ACI", "SDWAN", "IOSXE"). - - Returns: - The controller URL value from the environment. - - Raises: - KeyError: If no credential set env var has a URL value set. - - Example: - >>> os.environ["ACI_URL"] = "https://apic.example.com" - >>> get_controller_url("ACI") - 'https://apic.example.com' - - >>> os.environ["IOSXE_HOST"] = "192.168.1.1" - >>> get_controller_url("IOSXE") # Returns IOSXE_HOST when IOSXE_URL not set - '192.168.1.1' - """ - config = CONTROLLER_REGISTRY.get(controller_type) - - if config is None: - # Fallback for unknown controller types - return os.environ[f"{controller_type}_URL"] - - # Primary URL from the explicit url_env_var field - value = os.environ.get(config.url_env_var, "").strip() - if value: - return value - - # Fallback for alternative URL vars (e.g., IOSXE_HOST) - for cred_set in config.credential_sets: - if cred_set.env_vars[0] != config.url_env_var: - alt = os.environ.get(cred_set.env_vars[0], "").strip() - if alt: - return alt - - raise KeyError(config.url_env_var) - - -def get_matched_credential_set(controller_type: str) -> CredentialSet | None: - """Get the credential set that was matched during controller detection. - - Returns the CredentialSet that satisfied detection for the given controller - type. This is populated by detect_controller_type() and is intended for use - by auth adapters in nac-test-pyats-common to determine which authentication - mechanism to use (via the auth_method attribute). - - Args: - controller_type: The controller type key (e.g., "SDWAN", "ACI"). - - Returns: - The matched CredentialSet, or None if detect_controller_type() has not - been called or the controller type was not detected. - - Example: - >>> detect_controller_type() # populates the cache - 'SDWAN' - >>> cred = get_matched_credential_set("SDWAN") - >>> cred.auth_method - 'token' - >>> cred.label - 'API Token (20.18+)' - """ - return _matched_credential_sets.get(controller_type) +from nac_test.core.controller import ( # noqa: F401 + detect_controller_type, + get_matched_credential_set, +) diff --git a/nac_test/utils/environment.py b/nac_test/utils/environment.py deleted file mode 100644 index 9b2cc0d7..00000000 --- a/nac_test/utils/environment.py +++ /dev/null @@ -1,168 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -# Copyright (c) 2025 Daniel Schmidt - -"""Environment variable utilities for nac-test framework.""" - -import os -import sys -from collections.abc import Callable - -from nac_test.core.constants import EXIT_ERROR -from nac_test.utils.terminal import terminal - - -class EnvironmentValidator: - """Generic environment variable validation utilities.""" - - @staticmethod - def check_required_vars( - required_vars: list[str], - exit_on_missing: bool = True, - custom_formatter: Callable[[list[str]], str] | None = None, - ) -> list[str]: - """Check for required environment variables. - - Args: - required_vars: List of required environment variable names - exit_on_missing: Whether to exit if variables are missing - custom_formatter: Optional custom error formatter function - - Returns: - List of missing variable names (empty if all present) - - Raises: - SystemExit: If exit_on_missing is True and variables are missing - """ - missing = [var for var in required_vars if not os.environ.get(var)] - - if missing and exit_on_missing: - # Use custom formatter or default - if custom_formatter: - error_msg = custom_formatter(missing) - else: - error_msg = EnvironmentValidator.format_missing_vars_error(missing) - - print(error_msg) - sys.exit(EXIT_ERROR) - - return missing - - @staticmethod - def format_missing_vars_error(missing_vars: list[str]) -> str: - """Format a generic error message for missing environment variables. - - Args: - missing_vars: List of missing environment variable names - - Returns: - Formatted error message - """ - lines = [] - lines.append(terminal.header("ERROR: Missing environment variable(s)")) - lines.append("") - - for var in missing_vars: - lines.append(f" • {terminal.error(var)}") - - lines.append("") - lines.append( - terminal.info( - "Please set the required environment variables before running." - ) - ) - - return "\n".join(lines) - - @staticmethod - def get_with_default(var_name: str, default: str) -> str: - """Get environment variable with a default value. - - Args: - var_name: Environment variable name - default: Default value if not set - - Returns: - Environment variable value or default - """ - return os.environ.get(var_name, default) - - @staticmethod - def get_bool(var_name: str, default: bool = False) -> bool: - """Get environment variable as boolean. - - Args: - var_name: Environment variable name - default: Default value if not set - - Returns: - Boolean value (true/1/yes/on are True, everything else is False) - """ - value = os.environ.get(var_name, "").lower() - if not value: - return default - return value in ("true", "1", "yes", "on") - - @staticmethod - def get_int(var_name: str, default: int = 0) -> int: - """Get environment variable as integer. - - Args: - var_name: Environment variable name - default: Default value if not set or invalid - - Returns: - Integer value or default - """ - try: - return int(os.environ.get(var_name, str(default))) - except ValueError: - return default - - @staticmethod - def validate_controller_env(controller_type: str = "ACI") -> None: - """Validate controller-specific environment variables. - - Iterates through the controller's credential_sets. If any set is fully - satisfied (all env vars present and non-empty), validation passes. - Otherwise reports all accepted credential sets so the user knows - exactly which variables are needed. - - Args: - controller_type: Type of controller (ACI, CC, SDWAN, etc.) - - Raises: - SystemExit: If no credential set is fully satisfied - """ - from nac_test.utils.controller import ( - CONTROLLER_REGISTRY, - _format_incomplete_credentials_error, - _is_env_var_set, - ) - - config = CONTROLLER_REGISTRY.get(controller_type) - if config: - # Check each credential set — first fully satisfied wins - for cred_set in config.credential_sets: - if all(_is_env_var_set(v) for v in cred_set.env_vars): - return # Credentials satisfied - - # No set fully satisfied — report all accepted credential sets - error_msg = _format_incomplete_credentials_error([controller_type]) - sys.exit(error_msg) - else: - # Fallback for unknown controller types - required_vars = [ - f"{controller_type}_URL", - f"{controller_type}_USERNAME", - f"{controller_type}_PASSWORD", - ] - - # Use terminal's controller-specific formatter - def controller_formatter(missing: list[str]) -> str: - return terminal.format_env_var_error(missing, controller_type) - - EnvironmentValidator.check_required_vars( - required_vars, - exit_on_missing=True, - custom_formatter=controller_formatter, - ) diff --git a/nac_test/utils/terminal.py b/nac_test/utils/terminal.py index ee270605..3edf4b27 100644 --- a/nac_test/utils/terminal.py +++ b/nac_test/utils/terminal.py @@ -8,7 +8,6 @@ from colorama import Fore, Style, init -from nac_test.core.constants import SUMMARY_SEPARATOR_WIDTH from nac_test.core.types import CombinedResults # autoreset=True means colors reset after each print @@ -113,99 +112,6 @@ def header(cls, text: str, width: int = 70, char: str = "=") -> str: separator = char * width return f"{cls.ERROR}{separator}{cls.RESET}\n{cls.ERROR}{text}{cls.RESET}\n{cls.ERROR}{separator}{cls.RESET}" - @classmethod - def format_env_var_error( - cls, missing_vars: list[str], controller_type: str = "ACI" - ) -> str: - """Format an informative error message for missing environment variables. - - Generates an architecture-agnostic error message that educates users about - the auto-detection mechanism and provides examples for all supported - architectures. - - Args: - missing_vars: List of missing environment variable names - controller_type: Auto-detected controller type from available credentials - - Returns: - Formatted error message with ANSI color codes for terminal display - """ - lines = [] - lines.append(cls.header("ERROR: Missing required environment variable(s)")) - - # Show the missing variables - for var in missing_vars: - lines.append(f" {cls.warning('•')} {cls.warning(var)}") - - lines.append("") - - # Explain auto-detection mechanism - lines.append( - cls.info( - "The framework automatically detects which controller type to use based" - ) - ) - lines.append(cls.info("on the environment variables you provide.")) - lines.append("") - lines.append( - cls.info(f"Controller type detected: {cls.highlight(controller_type)}") - ) - lines.append("") - lines.append( - cls.info("This detection found some credentials but not all required ones.") - ) - - lines.append("") - lines.append(cls.info("To switch to a different controller:")) - lines.append(cls.info("1. Unset current controller's environment variables")) - lines.append( - cls.info( - "2. Set the new controller's credentials (URL, USERNAME, PASSWORD)" - ) - ) - lines.append("") - - # Show how to unset current controller's variables - lines.append(cls.info(f"To unset {controller_type} variables:")) - lines.append( - f" {cls.success(f'unset {controller_type}_URL {controller_type}_USERNAME {controller_type}_PASSWORD')}" - ) - lines.append("") - - lines.append(cls.info("Then set credentials for your desired controller:")) - lines.append("") - - # Architecture-specific examples with helpful URLs - architecture_examples = [ - ("ACI", "apic.example.com", "ACI (APIC)"), - ("SDWAN", "sdwan-manager.example.com", "SD-WAN (SDWAN Manager)"), - ("CC", "cc.example.com", "Catalyst Center"), - ("MERAKI", "api.meraki.com/api/v1", "Meraki"), - ("FMC", "fmc.example.com", "Firepower Management Center"), - ("ISE", "ise.example.com", "ISE"), - ] - - for arch, url, friendly_name in architecture_examples: - # Pre-compute strings to avoid nested f-string backslash limitation - url_cmd = f"export {arch}_URL='https://{url}'" - user_cmd = f"export {arch}_USERNAME='admin'" - pass_cmd = f"export {arch}_PASSWORD='your-password'" - lines.append(f" {cls.highlight(friendly_name + ':')}") - lines.append(f" {cls.success(url_cmd)}") - lines.append(f" {cls.success(user_cmd)}") - lines.append(f" {cls.success(pass_cmd)}") - lines.append("") - - lines.append(cls.info("The framework will automatically detect and use the")) - lines.append( - cls.info("controller type based on which credentials are present.") - ) - lines.append("") - - lines.append(cls.error("=" * SUMMARY_SEPARATOR_WIDTH)) - - return "\n".join(lines) - @classmethod def format_test_summary(cls, results: CombinedResults) -> str: """Format test results in Robot-style with colored numbers. diff --git a/tests/conftest.py b/tests/conftest.py index e6530751..e5b011ab 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,16 +7,19 @@ - Environment cleanup (controller credentials, proxy settings) - Mock API server for simulating controller responses - Class-scoped monkeypatch for environment variable management +- ControllerContext fixtures for common test scenarios """ import os -import re import tempfile from collections.abc import Generator from pathlib import Path import pytest +from nac_test.core.constants import ENV_CONTROLLER_CONTEXT +from nac_test.core.controller import CONTROLLER_REGISTRY +from nac_test.core.types import AuthMethod, ControllerContext from tests.e2e.mocks.mock_server import MockAPIServer # Path to the mock API configuration files @@ -47,21 +50,28 @@ def assert_is_link_to(link: Path, source: Path) -> None: # ============================================================================= -@pytest.fixture(scope="session", autouse=True) -def clear_controller_credentials() -> None: - """Clear any controller credentials from environment to avoid conflicts. +# Derive controller env var prefixes from registry - stays in sync automatically +CONTROLLER_ENV_PREFIXES = tuple(f"{key}_" for key in CONTROLLER_REGISTRY.keys()) + - nac-test fails if the user has controller credentials set in their - environment. This fixture removes any environment variables matching - the pattern: ^[A-Z]+_(URL|USERNAME|PASSWORD)$ +@pytest.fixture(autouse=True) +def clean_controller_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Clear all controller-related environment variables and caches. - Runs at session scope to ensure credentials are cleared before any - other fixtures that might set mock credentials. + Ensures tests run in isolation regardless of env var leakage + from other tests when running in parallel with pytest-xdist. """ - pattern = re.compile(r"^[A-Z]+_(URL|USERNAME|PASSWORD)$") - keys_to_remove = [key for key in os.environ.keys() if pattern.match(key)] - for key in keys_to_remove: - del os.environ[key] + for key in list(os.environ.keys()): + if any(key.startswith(prefix) for prefix in CONTROLLER_ENV_PREFIXES): + monkeypatch.delenv(key, raising=False) + + # Clear serialized controller context from previous tests + monkeypatch.delenv(ENV_CONTROLLER_CONTEXT, raising=False) + + # Clear module-level credential cache to prevent cross-test pollution + from nac_test.core import controller + + controller._matched_credential_sets.clear() @pytest.fixture(scope="session", autouse=True) @@ -154,3 +164,32 @@ def socket_dir() -> Generator[Path, None, None]: """Short-path temp dir suitable for Unix socket paths (macOS 104-char limit).""" with tempfile.TemporaryDirectory() as d: yield Path(d) + + +# ============================================================================= +# ControllerContext fixtures +# ============================================================================= + + +@pytest.fixture() +def aci_context() -> ControllerContext: + """Pre-built ControllerContext for ACI with session auth.""" + return ControllerContext(controller_type="ACI", auth_method=AuthMethod.SESSION) + + +@pytest.fixture() +def sdwan_context() -> ControllerContext: + """Pre-built ControllerContext for SDWAN with session auth.""" + return ControllerContext(controller_type="SDWAN", auth_method=AuthMethod.SESSION) + + +@pytest.fixture() +def cc_context() -> ControllerContext: + """Pre-built ControllerContext for Catalyst Center with session auth.""" + return ControllerContext(controller_type="CC", auth_method=AuthMethod.SESSION) + + +@pytest.fixture() +def iosxe_context() -> ControllerContext: + """Pre-built ControllerContext for IOS-XE with session auth.""" + return ControllerContext(controller_type="IOSXE", auth_method=AuthMethod.SESSION) diff --git a/tests/integration/test_cli_aci_validation.py b/tests/integration/test_cli_aci_validation.py index 2a7543d0..9dad95e2 100644 --- a/tests/integration/test_cli_aci_validation.py +++ b/tests/integration/test_cli_aci_validation.py @@ -26,19 +26,6 @@ class TestCliAciValidationIntegration: """Integration tests for ACI defaults validation through the CLI.""" - @pytest.fixture(autouse=True) - def clean_controller_env(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Clear all controller-related environment variables before each test. - - Ensures tests run in isolation regardless of the caller's shell environment. - """ - for key in list(os.environ.keys()): - if any( - prefix in key - for prefix in ["ACI_", "SDWAN_", "CC_", "MERAKI_", "FMC_", "ISE_"] - ): - monkeypatch.delenv(key, raising=False) - @pytest.fixture def minimal_test_env( self, tmp_path: Path @@ -206,22 +193,6 @@ class TestCliAciValidationSubprocess: end-to-end behavior including entry point wiring. """ - @pytest.fixture(autouse=True) - def clean_controller_env(self) -> Generator[None, None, None]: - """Clear ACI environment variables for subprocess tests.""" - original_env = os.environ.copy() - # Clear controller vars - for key in list(os.environ.keys()): - if any( - prefix in key - for prefix in ["ACI_", "SDWAN_", "CC_", "MERAKI_", "FMC_", "ISE_"] - ): - del os.environ[key] - yield - # Restore original environment - os.environ.clear() - os.environ.update(original_env) - @pytest.fixture def cli_test_env(self, tmp_path: Path) -> Generator[dict[str, Path], None, None]: """Create a minimal test environment for subprocess tests.""" diff --git a/tests/integration/test_controller_detection_integration.py b/tests/integration/test_controller_detection_integration.py index a74adc19..ed8fc18b 100644 --- a/tests/integration/test_controller_detection_integration.py +++ b/tests/integration/test_controller_detection_integration.py @@ -7,8 +7,8 @@ import pytest +from nac_test.core.controller import detect_controller_type from nac_test.pyats_core.orchestrator import PyATSOrchestrator -from nac_test.utils.controller import detect_controller_type class TestControllerDetectionIntegration: diff --git a/tests/integration/test_preflight_auth_integration.py b/tests/integration/test_preflight_auth_integration.py index 5ec65304..35b007f5 100644 --- a/tests/integration/test_preflight_auth_integration.py +++ b/tests/integration/test_preflight_auth_integration.py @@ -19,10 +19,11 @@ import pytest from pytest_httpserver import HTTPServer -from nac_test.cli.validators.controller_auth import ( +from nac_test.core.controller_auth import ( AuthOutcome, preflight_auth_check, ) +from nac_test.core.types import ControllerContext pytestmark = pytest.mark.integration @@ -34,6 +35,7 @@ def test_apic_auth_success_with_real_http_200( self, httpserver: HTTPServer, monkeypatch: pytest.MonkeyPatch, + aci_context: ControllerContext, ) -> None: """Success path: Real HTTP 200 from mock APIC returns success.""" httpserver.expect_request( @@ -59,7 +61,7 @@ def test_apic_auth_success_with_real_http_200( monkeypatch.setenv("ACI_USERNAME", "integration-test-user") monkeypatch.setenv("ACI_PASSWORD", "integration-test-password") - result = preflight_auth_check("ACI") + result = preflight_auth_check(aci_context) assert result.success is True assert result.reason == AuthOutcome.SUCCESS @@ -81,6 +83,7 @@ def test_apic_auth_bad_credentials_with_http_401( self, httpserver: HTTPServer, monkeypatch: pytest.MonkeyPatch, + aci_context: ControllerContext, ) -> None: """Failure path: HTTP 401 from mock APIC is classified as BAD_CREDENTIALS.""" httpserver.expect_request( @@ -106,7 +109,7 @@ def test_apic_auth_bad_credentials_with_http_401( monkeypatch.setenv("ACI_USERNAME", "wrong-user") monkeypatch.setenv("ACI_PASSWORD", "wrong-password") - result = preflight_auth_check("ACI") + result = preflight_auth_check(aci_context) assert result.success is False assert result.reason == AuthOutcome.BAD_CREDENTIALS @@ -116,6 +119,7 @@ def test_apic_auth_forbidden_with_http_403( self, httpserver: HTTPServer, monkeypatch: pytest.MonkeyPatch, + aci_context: ControllerContext, ) -> None: """Failure path: HTTP 403 from mock APIC is classified as BAD_CREDENTIALS.""" httpserver.expect_request( @@ -141,7 +145,7 @@ def test_apic_auth_forbidden_with_http_403( monkeypatch.setenv("ACI_USERNAME", "readonly-user") monkeypatch.setenv("ACI_PASSWORD", "some-password") - result = preflight_auth_check("ACI") + result = preflight_auth_check(aci_context) assert result.success is False assert result.reason == AuthOutcome.BAD_CREDENTIALS @@ -151,6 +155,7 @@ def test_apic_auth_forbidden_with_http_403( def test_apic_auth_unreachable_with_connection_refused( self, monkeypatch: pytest.MonkeyPatch, + aci_context: ControllerContext, ) -> None: """Failure path: Unreachable server is classified correctly. @@ -164,7 +169,7 @@ def test_apic_auth_unreachable_with_connection_refused( monkeypatch.setenv("ACI_USERNAME", "testuser") monkeypatch.setenv("ACI_PASSWORD", "testpass") - result = preflight_auth_check("ACI") + result = preflight_auth_check(aci_context) assert result.success is False assert result.reason == AuthOutcome.UNREACHABLE @@ -185,6 +190,7 @@ def test_sdwan_auth_success_with_session_cookie( self, httpserver: HTTPServer, monkeypatch: pytest.MonkeyPatch, + sdwan_context: ControllerContext, ) -> None: """Success path: Mock SDWAN Manager returns JSESSIONID and XSRF token.""" # Step 1: Form login returns JSESSIONID cookie @@ -210,7 +216,7 @@ def test_sdwan_auth_success_with_session_cookie( monkeypatch.setenv("SDWAN_USERNAME", "integration-test-user") monkeypatch.setenv("SDWAN_PASSWORD", "integration-test-password") - result = preflight_auth_check("SDWAN") + result = preflight_auth_check(sdwan_context) assert result.success is True assert result.reason == AuthOutcome.SUCCESS @@ -233,6 +239,7 @@ def test_sdwan_auth_bad_credentials_with_http_401( self, httpserver: HTTPServer, monkeypatch: pytest.MonkeyPatch, + sdwan_context: ControllerContext, ) -> None: """Failure path: HTTP 401 from mock SDWAN Manager is classified as BAD_CREDENTIALS.""" httpserver.expect_request( @@ -247,7 +254,7 @@ def test_sdwan_auth_bad_credentials_with_http_401( monkeypatch.setenv("SDWAN_USERNAME", "wrong-user") monkeypatch.setenv("SDWAN_PASSWORD", "wrong-password") - result = preflight_auth_check("SDWAN") + result = preflight_auth_check(sdwan_context) assert result.success is False assert result.reason == AuthOutcome.BAD_CREDENTIALS @@ -265,6 +272,7 @@ def test_cc_auth_success_with_token_response( self, httpserver: HTTPServer, monkeypatch: pytest.MonkeyPatch, + cc_context: ControllerContext, ) -> None: """Success path: Mock Catalyst Center returns auth token.""" httpserver.expect_request( @@ -279,7 +287,7 @@ def test_cc_auth_success_with_token_response( monkeypatch.setenv("CC_USERNAME", "integration-test-user") monkeypatch.setenv("CC_PASSWORD", "integration-test-password") - result = preflight_auth_check("CC") + result = preflight_auth_check(cc_context) assert result.success is True assert result.reason == AuthOutcome.SUCCESS @@ -301,6 +309,7 @@ def test_cc_auth_bad_credentials_with_http_401( self, httpserver: HTTPServer, monkeypatch: pytest.MonkeyPatch, + cc_context: ControllerContext, ) -> None: """Failure path: HTTP 401 from mock Catalyst Center is classified as BAD_CREDENTIALS. @@ -327,7 +336,7 @@ def test_cc_auth_bad_credentials_with_http_401( monkeypatch.setenv("CC_USERNAME", "wrong-user") monkeypatch.setenv("CC_PASSWORD", "wrong-password") - result = preflight_auth_check("CC") + result = preflight_auth_check(cc_context) assert result.success is False assert result.reason == AuthOutcome.BAD_CREDENTIALS diff --git a/tests/integration/test_url_normalization.py b/tests/integration/test_url_normalization.py new file mode 100644 index 00000000..52185887 --- /dev/null +++ b/tests/integration/test_url_normalization.py @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2025 Daniel Schmidt +"""Contract: auth adapters normalize trailing slashes in controller URLs. + +Verifies that authentication endpoints constructed by pyats-common adapters +never contain double-slashes, regardless of whether the user sets a trailing / +on the controller URL env var. + +The httpserver expects clean paths (e.g. /api/aaaLogin.json). If an adapter +fails to strip the trailing slash, it would request //api/aaaLogin.json which +won't match the handler — causing an auth failure and a test assertion error. +""" + +import pytest +from pytest_httpserver import HTTPServer + +from nac_test.core.controller_auth import ( + AuthOutcome, + preflight_auth_check, +) +from nac_test.core.types import ControllerContext + +pytestmark = pytest.mark.integration + + +@pytest.mark.parametrize("url_suffix", ["", "/"], ids=["clean", "trailing_slash"]) +def test_aci_preflight_normalizes_url( + httpserver: HTTPServer, + monkeypatch: pytest.MonkeyPatch, + url_suffix: str, + aci_context: ControllerContext, +) -> None: + """APIC auth succeeds regardless of trailing slash on ACI_URL.""" + httpserver.expect_request("/api/aaaLogin.json", method="POST").respond_with_json( + { + "imdata": [ + { + "aaaLogin": { + "attributes": { + "token": "test-token", + "refreshTimeoutSeconds": "600", + } + } + } + ] + }, + status=200, + ) + + monkeypatch.setenv("ACI_URL", httpserver.url_for("") + url_suffix) + monkeypatch.setenv("ACI_USERNAME", "admin") + monkeypatch.setenv("ACI_PASSWORD", "password") + + result = preflight_auth_check(aci_context) + + assert result.success is True, ( + f"Auth failed with url_suffix={url_suffix!r}: {result.detail}" + ) + assert result.reason == AuthOutcome.SUCCESS + + +@pytest.mark.parametrize("url_suffix", ["", "/"], ids=["clean", "trailing_slash"]) +def test_sdwan_preflight_normalizes_url( + httpserver: HTTPServer, + monkeypatch: pytest.MonkeyPatch, + url_suffix: str, + sdwan_context: ControllerContext, +) -> None: + """SDWAN session auth succeeds regardless of trailing slash on SDWAN_URL.""" + httpserver.expect_request("/j_security_check", method="POST").respond_with_data( + "", + status=200, + headers={"Set-Cookie": "JSESSIONID=test-session; Path=/"}, + ) + httpserver.expect_request( + "/dataservice/client/token", method="GET" + ).respond_with_data( + "test-xsrf-token", + status=200, + ) + + monkeypatch.setenv("SDWAN_URL", httpserver.url_for("") + url_suffix) + monkeypatch.setenv("SDWAN_USERNAME", "admin") + monkeypatch.setenv("SDWAN_PASSWORD", "password") + + result = preflight_auth_check(sdwan_context) + + assert result.success is True, ( + f"Auth failed with url_suffix={url_suffix!r}: {result.detail}" + ) + assert result.reason == AuthOutcome.SUCCESS diff --git a/tests/pyats_core/common/test_auth_cache.py b/tests/pyats_core/common/test_auth_cache.py index a367f2bb..4ca25fbe 100644 --- a/tests/pyats_core/common/test_auth_cache.py +++ b/tests/pyats_core/common/test_auth_cache.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2025 Daniel Schmidt -"""Unit tests for nac_test.pyats_core.common.auth_cache module. +"""Unit tests for nac_test.core.auth_cache module. This module tests the AuthCache class which provides generic file-based authentication caching for parallel processes. The tests cover: @@ -21,7 +21,7 @@ import pytest from pytest_mock import MockerFixture -from nac_test.pyats_core.common.auth_cache import AuthCache +from nac_test.core.auth_cache import AuthCache @pytest.fixture @@ -34,9 +34,7 @@ def mock_time(mocker: MockerFixture) -> Any: Returns: Mock object for time.time that can be configured per test. """ - return mocker.patch( - "nac_test.pyats_core.common.auth_cache.time.time", return_value=1000.0 - ) + return mocker.patch("nac_test.core.auth_cache.time.time", return_value=1000.0) @pytest.fixture @@ -52,7 +50,7 @@ def mock_auth_cache_dir(mocker: MockerFixture, tmp_path: Path) -> Path: """ cache_dir = tmp_path / "auth-cache" cache_dir.mkdir(exist_ok=True) - mocker.patch("nac_test.pyats_core.common.auth_cache.AUTH_CACHE_DIR", str(cache_dir)) + mocker.patch("nac_test.core.auth_cache.AUTH_CACHE_DIR", str(cache_dir)) return cache_dir @@ -73,9 +71,7 @@ def mock_fcntl(mocker: MockerFixture) -> Any: mock_lock = mocker.MagicMock() mock_lock.__enter__ = mocker.MagicMock(return_value=None) mock_lock.__exit__ = mocker.MagicMock(return_value=None) - return mocker.patch( - "nac_test.pyats_core.common.auth_cache.FileLock", return_value=mock_lock - ) + return mocker.patch("nac_test.core.auth_cache.FileLock", return_value=mock_lock) @pytest.fixture @@ -891,7 +887,7 @@ def test_ttl_behavior( should_refresh: Whether the cache should be refreshed. """ # Arrange - time_mock = mocker.patch("nac_test.pyats_core.common.auth_cache.time.time") + time_mock = mocker.patch("nac_test.core.auth_cache.time.time") time_mock.return_value = initial_time auth_call_count = 0 @@ -974,7 +970,7 @@ def test_cache_dir_creation( # Arrange non_existent_dir = tmp_path / "new-cache-dir" mocker.patch( - "nac_test.pyats_core.common.auth_cache.AUTH_CACHE_DIR", + "nac_test.core.auth_cache.AUTH_CACHE_DIR", str(non_existent_dir), ) @@ -1121,7 +1117,7 @@ def test_invalidate_does_not_raise_on_permission_error( # Make FileLock raise an OSError to simulate a permission issue mocker.patch( - "nac_test.pyats_core.common.auth_cache.FileLock", + "nac_test.core.auth_cache.FileLock", side_effect=OSError("Permission denied"), ) diff --git a/tests/pyats_core/common/test_base_test_controller_detection.py b/tests/pyats_core/common/test_base_test_controller_detection.py index 7b829431..e6cdd44a 100644 --- a/tests/pyats_core/common/test_base_test_controller_detection.py +++ b/tests/pyats_core/common/test_base_test_controller_detection.py @@ -3,6 +3,7 @@ """Test base_test.py controller detection integration.""" +import logging from collections.abc import Generator from pathlib import Path from unittest.mock import patch @@ -10,6 +11,8 @@ import pytest from pyats import aetest +from nac_test.core.constants import ENV_CONTROLLER_CONTEXT +from nac_test.core.types import AuthMethod, ControllerContext from nac_test.pyats_core.common.base_test import NACTestBase @@ -51,6 +54,41 @@ def test_method(self) -> None: assert test_instance.controller_url == "https://apic.example.com" assert test_instance.username == "admin" assert test_instance.password == "password" + assert test_instance.auth_method == "session" + assert test_instance.connection_params == { + "url": "https://apic.example.com", + "username": "admin", + "password": "password", + } + + def test_base_test_connection_params_populated_for_iosxe( + self, monkeypatch: pytest.MonkeyPatch, setup_test_data_file_env: Path + ) -> None: + """connection_params resolves for IOSXE too, now that kinds are populated.""" + for env_var in ["ACI_URL", "SDWAN_URL", "CC_URL"]: + monkeypatch.delenv(env_var, raising=False) + monkeypatch.setenv("IOSXE_URL", "10.0.0.1") + monkeypatch.setenv("IOSXE_USERNAME", "admin") + monkeypatch.setenv("IOSXE_PASSWORD", "password") + + class TestClass(NACTestBase): + @aetest.test # type: ignore[misc] + def test_method(self) -> None: + pass + + test_instance = TestClass() + + with patch.object( + test_instance, "load_data_model", return_value={"test": "data"} + ): + test_instance.setup() + + assert test_instance.controller_type == "IOSXE" + assert test_instance.connection_params == { + "url": "10.0.0.1", + "username": "admin", + "password": "password", + } def test_base_test_fails_setup_on_detection_error( self, monkeypatch: pytest.MonkeyPatch, setup_test_data_file_env: Path @@ -133,3 +171,92 @@ def test_method(self) -> None: test_instance.setup() assert "Multiple controller credentials detected" in str(exc_info.value) + + def test_base_test_uses_serialized_controller_context( + self, monkeypatch: pytest.MonkeyPatch, setup_test_data_file_env: Path + ) -> None: + """Test that NACTestBase uses NAC_TEST_CONTROLLER_CONTEXT when present (primary path).""" + # Set serialized context (primary path) AND the underlying env vars + ctx = ControllerContext(controller_type="SDWAN", auth_method=AuthMethod.TOKEN) + monkeypatch.setenv(ENV_CONTROLLER_CONTEXT, ctx.to_json()) + # Need SDWAN env vars for get_controller_url() and get_connection_params() + monkeypatch.setenv("SDWAN_URL", "https://vmanage.example.com") + monkeypatch.setenv("SDWAN_API_TOKEN", "test-token-value") + + class TestClass(NACTestBase): + @aetest.test # type: ignore[misc] + def test_method(self) -> None: + pass + + test_instance = TestClass() + with patch.object( + test_instance, "load_data_model", return_value={"test": "data"} + ): + test_instance.setup() + + assert test_instance.controller_type == "SDWAN" + assert test_instance.auth_method == "token" + assert test_instance.connection_params["token"] == "test-token-value" + + +class TestBaseTestSetupErrorLogging: + """Test that setup() logs errors via self.logger before re-raising. + + Uses real env var injection instead of patching get_controller_context so + the tests are immune to mock-machinery differences across Python versions. + """ + + def _make_test_instance(self) -> "NACTestBase": + class TestClass(NACTestBase): + @aetest.test # type: ignore[misc] + def test_method(self) -> None: + pass + + return TestClass() + + @pytest.mark.parametrize( + "context_env,exc_type", + [ + # NAC_TEST_CONTROLLER_CONTEXT absent + no controller env vars → ValueError + (None, ValueError), + # valid JSON but missing controller_type field → KeyError + ('{"auth_method": "basic"}', KeyError), + # malformed JSON → ValueError (wrapped from JSONDecodeError) + ("not-valid-json", ValueError), + ], + ids=[ + "value_error", + "key_error-missing_field", + "value_error-malformed_json", + ], + ) + def test_setup_logs_error_before_reraise( + self, + context_env: str | None, + exc_type: type[Exception], + setup_test_data_file_env: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + ) -> None: + """setup() calls self.logger.error with 'Controller detection failed' before + re-raising ValueError, KeyError, or JSONDecodeError from get_controller_context(). + """ + if context_env is None: + monkeypatch.delenv(ENV_CONTROLLER_CONTEXT, raising=False) + else: + monkeypatch.setenv(ENV_CONTROLLER_CONTEXT, context_env) + + test_instance = self._make_test_instance() + + with patch.object( + test_instance, "load_data_model", return_value={"test": "data"} + ): + with caplog.at_level(logging.ERROR): + with pytest.raises(exc_type): + test_instance.setup() + + assert any( + "Controller detection failed" in r.message + for r in caplog.records + if r.levelno == logging.ERROR + ) diff --git a/tests/pyats_core/conftest.py b/tests/pyats_core/conftest.py index 3db09c71..12aa979e 100644 --- a/tests/pyats_core/conftest.py +++ b/tests/pyats_core/conftest.py @@ -8,15 +8,12 @@ fixtures should be consolidated into a single conftest.py. """ -import os from pathlib import Path from typing import NamedTuple import pytest from _pytest.monkeypatch import MonkeyPatch -CONTROLLER_ENV_PREFIXES = ("ACI_", "SDWAN_", "CC_", "MERAKI_", "FMC_", "ISE_") - class PyATSTestDirs(NamedTuple): """Directory structure for PyATS orchestrator tests.""" @@ -26,17 +23,6 @@ class PyATSTestDirs(NamedTuple): merged_file: Path -@pytest.fixture(autouse=True) -def clean_controller_env(monkeypatch: MonkeyPatch) -> None: - """Clear all controller-related environment variables. - - Ensures tests run in isolation regardless of the caller's shell environment. - """ - for key in list(os.environ.keys()): - if any(prefix in key for prefix in CONTROLLER_ENV_PREFIXES): - monkeypatch.delenv(key, raising=False) - - @pytest.fixture() def aci_controller_env(monkeypatch: MonkeyPatch) -> None: """Set ACI controller environment variables.""" diff --git a/tests/pyats_core/test_orchestrator_controller_detection.py b/tests/pyats_core/test_orchestrator_controller_detection.py index 0b0d5002..ebe6bb06 100644 --- a/tests/pyats_core/test_orchestrator_controller_detection.py +++ b/tests/pyats_core/test_orchestrator_controller_detection.py @@ -3,8 +3,6 @@ """Test PyATS orchestrator controller detection integration.""" -from unittest.mock import patch - import pytest from nac_test.core.constants import EXIT_ERROR @@ -59,24 +57,6 @@ def test_orchestrator_handles_multiple_controllers_error( # Verify it exits with EXIT_ERROR (255) for infrastructure errors assert exc_info.value.code == EXIT_ERROR - def test_validate_environment_uses_detected_controller( - self, sdwan_controller_env: None, pyats_test_dirs: PyATSTestDirs - ) -> None: - """Test that validate_environment uses the detected controller type.""" - orchestrator = PyATSOrchestrator( - data_paths=[pyats_test_dirs.output_dir.parent / "data.yaml"], - test_dir=pyats_test_dirs.test_dir, - output_dir=pyats_test_dirs.output_dir, - ) - - assert orchestrator.controller_type == "SDWAN" - - with patch( - "nac_test.pyats_core.orchestrator.EnvironmentValidator" - ) as mock_validator: - orchestrator.validate_environment() - mock_validator.validate_controller_env.assert_called_once_with("SDWAN") - def test_orchestrator_no_longer_uses_controller_type_env_var( self, aci_controller_env: None, diff --git a/tests/pyats_core/test_orchestrator_controller_param.py b/tests/pyats_core/test_orchestrator_controller_param.py index d9083d4a..1ae53e4a 100644 --- a/tests/pyats_core/test_orchestrator_controller_param.py +++ b/tests/pyats_core/test_orchestrator_controller_param.py @@ -1,44 +1,49 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2025 Daniel Schmidt -"""Unit tests for PyATSOrchestrator controller_type parameter.""" +"""Unit tests for PyATSOrchestrator controller_context parameter.""" from unittest.mock import patch +from nac_test.core.types import AuthMethod, ControllerContext from nac_test.pyats_core.orchestrator import PyATSOrchestrator from .conftest import PyATSTestDirs class TestOrchestratorControllerParam: - """Tests for PyATSOrchestrator controller_type parameter.""" + """Tests for PyATSOrchestrator controller_context parameter.""" - def test_orchestrator_uses_provided_controller_type( + def test_orchestrator_uses_provided_controller_context( self, clean_controller_env: None, pyats_test_dirs: PyATSTestDirs ) -> None: - """Test that PyATSOrchestrator uses provided controller_type instead of detecting.""" + """Test that PyATSOrchestrator uses provided controller_context instead of detecting.""" + controller_context = ControllerContext( + controller_type="SDWAN", auth_method=AuthMethod.TOKEN + ) + with patch( - "nac_test.pyats_core.orchestrator.detect_controller_type" - ) as mock_detect: + "nac_test.pyats_core.orchestrator.resolve_controller" + ) as mock_resolve: orchestrator = PyATSOrchestrator( data_paths=[pyats_test_dirs.output_dir.parent / "data"], test_dir=pyats_test_dirs.test_dir, output_dir=pyats_test_dirs.output_dir, - controller_type="SDWAN", + controller_context=controller_context, ) assert orchestrator.controller_type == "SDWAN" - mock_detect.assert_not_called() + mock_resolve.assert_not_called() def test_orchestrator_falls_back_to_detection_when_none( self, aci_controller_env: None, pyats_test_dirs: PyATSTestDirs ) -> None: - """Test that PyATSOrchestrator detects controller when controller_type is None.""" + """Test that PyATSOrchestrator detects controller when controller_context is None.""" orchestrator = PyATSOrchestrator( data_paths=[pyats_test_dirs.output_dir.parent / "data"], test_dir=pyats_test_dirs.test_dir, output_dir=pyats_test_dirs.output_dir, - controller_type=None, + controller_context=None, ) assert orchestrator.controller_type == "ACI" @@ -54,20 +59,3 @@ def test_orchestrator_defaults_to_detection( ) assert orchestrator.controller_type == "CC" - - def test_validate_environment_uses_provided_controller( - self, clean_controller_env: None, pyats_test_dirs: PyATSTestDirs - ) -> None: - """Test that validate_environment uses the provided controller type.""" - orchestrator = PyATSOrchestrator( - data_paths=[pyats_test_dirs.output_dir.parent / "data"], - test_dir=pyats_test_dirs.test_dir, - output_dir=pyats_test_dirs.output_dir, - controller_type="ACI", - ) - - with patch( - "nac_test.pyats_core.orchestrator.EnvironmentValidator.validate_controller_env" - ) as mock_validate: - orchestrator.validate_environment() - mock_validate.assert_called_once_with("ACI") diff --git a/tests/pyats_core/test_orchestrator_dry_run.py b/tests/pyats_core/test_orchestrator_dry_run.py index 76c339bd..36903e2e 100644 --- a/tests/pyats_core/test_orchestrator_dry_run.py +++ b/tests/pyats_core/test_orchestrator_dry_run.py @@ -58,7 +58,6 @@ def test_dry_run_prints_summary_and_skips_execution( patch.object( orchestrator.test_discovery, "discover_pyats_tests" ) as mock_discover, - patch.object(orchestrator, "validate_environment"), ): mock_discover.return_value = mock_plan result = orchestrator.run_tests() @@ -100,7 +99,6 @@ def test_dry_run_returns_not_run_results_for_api_and_d2d( patch.object( orchestrator.test_discovery, "discover_pyats_tests" ) as mock_discover, - patch.object(orchestrator, "validate_environment"), ): mock_discover.return_value = mock_plan result = orchestrator.run_tests() @@ -131,7 +129,6 @@ def test_dry_run_does_not_execute_tests( patch.object( orchestrator.test_discovery, "discover_pyats_tests" ) as mock_discover, - patch.object(orchestrator, "validate_environment"), patch.object(orchestrator, "_execute_api_tests_standard") as mock_execute, ): mock_discover.return_value = mock_plan @@ -159,7 +156,6 @@ def test_dry_run_empty_test_lists( patch.object( orchestrator.test_discovery, "discover_pyats_tests" ) as mock_discover, - patch.object(orchestrator, "validate_environment"), ): mock_discover.return_value = mock_plan result = orchestrator.run_tests() diff --git a/tests/pyats_core/test_orchestrator_env_var.py b/tests/pyats_core/test_orchestrator_env_var.py index ca22ec65..079e374f 100644 --- a/tests/pyats_core/test_orchestrator_env_var.py +++ b/tests/pyats_core/test_orchestrator_env_var.py @@ -9,6 +9,9 @@ import pytest +from nac_test.core.constants import ENV_CONTROLLER_CONTEXT +from nac_test.core.controller import get_controller_context +from nac_test.core.types import AuthMethod, ControllerContext from nac_test.pyats_core.constants import ENV_TEST_DIR from nac_test.pyats_core.execution.subprocess_runner import SubprocessRunner from nac_test.pyats_core.orchestrator import PyATSOrchestrator @@ -24,6 +27,7 @@ def test_orchestrator_respects_nac_test_pyats_processes_env_var( pyats_test_dirs: PyATSTestDirs, aci_controller_env: None, monkeypatch: pytest.MonkeyPatch, + aci_context: ControllerContext, ) -> None: """Verify orchestrator passes correct env_var to calculate_worker_capacity.""" monkeypatch.setenv("NAC_TEST_PYATS_PROCESSES", "123456") @@ -32,7 +36,7 @@ def test_orchestrator_respects_nac_test_pyats_processes_env_var( data_paths=[pyats_test_dirs.test_dir.parent / "data"], test_dir=pyats_test_dirs.test_dir, output_dir=pyats_test_dirs.output_dir, - controller_type="ACI", + controller_context=aci_context, ) assert orchestrator.max_workers == 123456 @@ -45,13 +49,14 @@ def test_execute_api_tests_includes_env_test_dir( self, pyats_test_dirs: PyATSTestDirs, aci_controller_env: None, + aci_context: ControllerContext, ) -> None: """Orchestrator passes ENV_TEST_DIR=str(test_dir) to subprocess_runner.execute_job.""" orchestrator = PyATSOrchestrator( data_paths=[pyats_test_dirs.test_dir.parent / "data"], test_dir=pyats_test_dirs.test_dir, output_dir=pyats_test_dirs.output_dir, - controller_type="ACI", + controller_context=aci_context, ) captured_env: dict[str, str] = {} @@ -71,3 +76,70 @@ async def capture_execute_job(_: Path, env: dict[str, str]) -> None: assert ENV_TEST_DIR in captured_env assert captured_env[ENV_TEST_DIR] == str(pyats_test_dirs.test_dir) + + +class TestControllerContextRoundTrip: + """End-to-end round-trip: orchestrator serializes → subprocess deserializes. + + The serialization side (PyATSOrchestrator._execute_api_tests_standard) + and the deserialization side (get_controller_context → from_json) are + each tested in isolation elsewhere. This test chains them to prove the + full round-trip contract. + """ + + @pytest.mark.parametrize( + "controller_type,auth_method", + [ + ("ACI", AuthMethod.SESSION), + ("SDWAN", AuthMethod.TOKEN), + ("CC", AuthMethod.SESSION), + ], + ids=["aci-session", "sdwan-token", "cc-session"], + ) + def test_orchestrator_serialized_context_round_trips( + self, + pyats_test_dirs: PyATSTestDirs, + aci_controller_env: None, + monkeypatch: pytest.MonkeyPatch, + controller_type: str, + auth_method: AuthMethod, + ) -> None: + """Serialized ENV_CONTROLLER_CONTEXT from orchestrator deserializes + back to an identical ControllerContext via get_controller_context().""" + original = ControllerContext( + controller_type=controller_type, # type: ignore[arg-type] + auth_method=auth_method, + ) + orchestrator = PyATSOrchestrator( + data_paths=[pyats_test_dirs.test_dir.parent / "data"], + test_dir=pyats_test_dirs.test_dir, + output_dir=pyats_test_dirs.output_dir, + controller_context=original, + ) + + # Capture the env dict that orchestrator passes to execute_job + captured_env: dict[str, str] = {} + + async def capture_execute_job(_: Path, env: dict[str, str]) -> None: + captured_env.update(env) + return None + + mock_runner = MagicMock(spec=SubprocessRunner) + mock_runner.execute_job = AsyncMock(side_effect=capture_execute_job) + orchestrator.subprocess_runner = mock_runner + + test_file = pyats_test_dirs.test_dir / "verify_round_trip.py" + test_file.touch() + asyncio.run(orchestrator._execute_api_tests_standard([test_file])) + + # Verify ENV_CONTROLLER_CONTEXT was set in the subprocess env + assert ENV_CONTROLLER_CONTEXT in captured_env + + # Simulate subprocess side: set env var and deserialize + monkeypatch.setenv(ENV_CONTROLLER_CONTEXT, captured_env[ENV_CONTROLLER_CONTEXT]) + restored = get_controller_context() + + # Round-trip must produce an identical ControllerContext + assert restored == original + assert restored.controller_type == controller_type + assert restored.auth_method == auth_method diff --git a/tests/unit/cli/test_cli_controller_auth.py b/tests/unit/cli/test_cli_controller_auth.py index 13c1ddf7..0adb2a38 100644 --- a/tests/unit/cli/test_cli_controller_auth.py +++ b/tests/unit/cli/test_cli_controller_auth.py @@ -17,11 +17,12 @@ from typer.testing import CliRunner import nac_test.cli.main -from nac_test.cli.validators.controller_auth import ( +from nac_test.core.controller_auth import ( AuthCheckResult, AuthOutcome, preflight_auth_check, ) +from nac_test.core.types import AuthMethod, ControllerContext, ControllerTypeKey class TestPreflightAuthCli: @@ -265,7 +266,7 @@ class TestPreflightCacheInvalidation: ) def test_cache_invalidated_before_auth_for_each_controller( self, - controller_type: str, + controller_type: ControllerTypeKey, url_env_var: str, url_value: str, expected_cache_key: str, @@ -295,15 +296,20 @@ def mock_invalidate(cache_key: str, url: str) -> None: with ( patch( - "nac_test.cli.validators.controller_auth._get_auth_callable", + "nac_test.core.controller_auth._get_auth_callable", return_value=mock_auth, ), patch( - "nac_test.cli.validators.controller_auth.AuthCache.invalidate", + "nac_test.core.controller_auth.AuthCache.invalidate", side_effect=mock_invalidate, ) as patched_invalidate, ): - result = preflight_auth_check(controller_type) # type: ignore[arg-type] + result = preflight_auth_check( + ControllerContext( + controller_type=controller_type, + auth_method=AuthMethod.SESSION, + ) + ) assert result.success is True patched_invalidate.assert_called_once_with( @@ -315,20 +321,21 @@ def mock_invalidate(cache_key: str, url: str) -> None: def test_cache_not_invalidated_when_no_auth_adapter( self, monkeypatch: pytest.MonkeyPatch, + aci_context: ControllerContext, ) -> None: """Controllers without an auth adapter skip cache invalidation entirely.""" - monkeypatch.setenv("MERAKI_URL", "https://meraki.test.local") + monkeypatch.setenv("ACI_URL", "https://apic.test.local") with ( patch( - "nac_test.cli.validators.controller_auth._get_auth_callable", + "nac_test.core.controller_auth._get_auth_callable", return_value=None, ), patch( - "nac_test.cli.validators.controller_auth.AuthCache.invalidate", + "nac_test.core.controller_auth.AuthCache.invalidate", ) as patched_invalidate, ): - result = preflight_auth_check("MERAKI") + result = preflight_auth_check(aci_context) # Skipped because no auth adapter — invalidate should not be called assert result.success is True @@ -337,6 +344,7 @@ def test_cache_not_invalidated_when_no_auth_adapter( def test_cache_invalidation_failure_does_not_block_auth( self, monkeypatch: pytest.MonkeyPatch, + aci_context: ControllerContext, ) -> None: """A failure in AuthCache.invalidate must not prevent authentication.""" monkeypatch.setenv("ACI_URL", "https://apic.test.local") @@ -346,15 +354,15 @@ def mock_auth() -> dict[str, str]: with ( patch( - "nac_test.cli.validators.controller_auth._get_auth_callable", + "nac_test.core.controller_auth._get_auth_callable", return_value=mock_auth, ), patch( - "nac_test.cli.validators.controller_auth.AuthCache.invalidate", + "nac_test.core.controller_auth.AuthCache.invalidate", side_effect=OSError("disk on fire"), ), ): - result = preflight_auth_check("ACI") + result = preflight_auth_check(aci_context) # Auth should still succeed despite invalidation failure assert result.success is True diff --git a/tests/unit/cli/ui/test_banners.py b/tests/unit/cli/ui/test_banners.py index e0106bde..7ec293d3 100644 --- a/tests/unit/cli/ui/test_banners.py +++ b/tests/unit/cli/ui/test_banners.py @@ -70,7 +70,6 @@ def test_contains_controller_type_display_name(self) -> None: controller_type="ACI", controller_url="https://apic.example.com", detail="HTTP 401: Unauthorized", - env_var_prefix="ACI", ) content = output.getvalue() @@ -84,7 +83,6 @@ def test_contains_controller_url(self) -> None: controller_type="SDWAN", controller_url="https://sdwan-manager.lab.local", detail="HTTP 403: Forbidden", - env_var_prefix="SDWAN", ) content = output.getvalue() @@ -98,7 +96,6 @@ def test_contains_credential_env_var_hints(self) -> None: controller_type="CC", controller_url="https://catc.example.com", detail="HTTP 401: Unauthorized", - env_var_prefix="CC", ) content = output.getvalue() @@ -113,7 +110,6 @@ def test_contains_auth_failed_message(self) -> None: controller_type="ACI", controller_url="https://apic.example.com", detail="HTTP 401: Unauthorized - Invalid credentials", - env_var_prefix="ACI", ) content = output.getvalue() @@ -132,7 +128,6 @@ def test_respects_no_color_mode(self) -> None: controller_type="ACI", controller_url="https://apic.example.com", detail="HTTP 401: Unauthorized", - env_var_prefix="ACI", ) content = output.getvalue() @@ -312,7 +307,6 @@ def test_auth_failure_banner_long_url_fits_in_box(self) -> None: controller_type="CC", controller_url=long_url, detail="HTTP 401: Unauthorized", - env_var_prefix="CC", ) lines = output.getvalue().splitlines() diff --git a/tests/unit/cli/validators/test_controller_auth.py b/tests/unit/cli/validators/test_controller_auth.py deleted file mode 100644 index 70182bdc..00000000 --- a/tests/unit/cli/validators/test_controller_auth.py +++ /dev/null @@ -1,374 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -# Copyright (c) 2025 Daniel Schmidt -"""Unit tests for pre-flight controller authentication validator. - -These tests verify the business logic of the pre-flight auth check, -ensuring the CLI correctly identifies authentication failures and -classifies them appropriately. -""" - -from unittest.mock import MagicMock, patch - -from _pytest.monkeypatch import MonkeyPatch - -from nac_test.cli.validators.controller_auth import ( - CONTROLLER_REGISTRY, - AuthOutcome, - _get_auth_callable, - _get_controller_url, - classify_auth_error, - preflight_auth_check, -) -from nac_test.core.error_classification import extract_http_status_code - - -class TestControllerRegistry: - """Tests for CONTROLLER_REGISTRY configuration.""" - - def test_registry_covers_all_supported_controllers(self) -> None: - """Registry includes all supported controller types with valid configs.""" - # After consolidation: CONTROLLER_REGISTRY now includes ALL controllers - expected_controllers = {"ACI", "SDWAN", "CC", "MERAKI", "FMC", "ISE", "IOSXE"} - assert set(CONTROLLER_REGISTRY.keys()) == expected_controllers - - for controller_type, config in CONTROLLER_REGISTRY.items(): - assert config.display_name, f"{controller_type} missing display_name" - assert config.url_env_var, f"{controller_type} missing url_env_var" - assert config.env_var_prefix, f"{controller_type} missing env_var_prefix" - - -class TestGetControllerUrl: - """Tests for _get_controller_url helper function.""" - - def test_returns_url_from_env_var(self, monkeypatch: MonkeyPatch) -> None: - """Returns URL from the appropriate environment variable.""" - monkeypatch.setenv("ACI_URL", "https://apic.example.com") - - result = _get_controller_url("ACI") - - assert result == "https://apic.example.com" - - def test_strips_trailing_slash(self, monkeypatch: MonkeyPatch) -> None: - """Removes trailing slash from URL.""" - monkeypatch.setenv("SDWAN_URL", "https://sdwan.example.com/") - - result = _get_controller_url("SDWAN") - - assert result == "https://sdwan.example.com" - - def test_returns_empty_string_when_not_set(self, monkeypatch: MonkeyPatch) -> None: - """Returns empty string when env var not set.""" - monkeypatch.delenv("CC_URL", raising=False) - - result = _get_controller_url("CC") - - assert result == "" - - def test_returns_empty_string_for_unknown_controller( - self, monkeypatch: MonkeyPatch - ) -> None: - """Returns empty string for unknown controller type.""" - result = _get_controller_url("UNKNOWN_CONTROLLER") - - assert result == "" - - -class TestClassifyAuthError: - """Tests for classify_auth_error helper function.""" - - def test_classifies_401_as_bad_credentials(self) -> None: - """HTTP 401 errors are classified as bad credentials.""" - error = Exception("HTTP 401: Unauthorized") - - reason, detail = classify_auth_error(error) - - assert reason == AuthOutcome.BAD_CREDENTIALS - assert detail == "HTTP 401: Unauthorized" - - def test_classifies_403_as_bad_credentials(self) -> None: - """HTTP 403 errors are classified as bad credentials.""" - error = Exception("HTTP 403: Forbidden - insufficient privileges") - - reason, detail = classify_auth_error(error) - - assert reason == AuthOutcome.BAD_CREDENTIALS - assert detail == "HTTP 403: Forbidden" - - def test_classifies_timeout_as_unreachable(self) -> None: - """Timeout errors are classified as unreachable.""" - error = Exception("Connection timed out after 30 seconds") - - reason, detail = classify_auth_error(error) - - assert reason == AuthOutcome.UNREACHABLE - assert "timed out" in detail.lower() - - def test_classifies_connection_refused_as_unreachable(self) -> None: - """Connection refused errors are classified as unreachable.""" - error = Exception("Connection refused on port 443") - - reason, detail = classify_auth_error(error) - - assert reason == AuthOutcome.UNREACHABLE - - def test_classifies_dns_failure_as_unreachable(self) -> None: - """DNS resolution failures are classified as unreachable.""" - error = Exception("Name or service not known: apic.example.com") - - reason, detail = classify_auth_error(error) - - assert reason == AuthOutcome.UNREACHABLE - - def test_classifies_unknown_as_unexpected_error(self) -> None: - """Unknown errors are classified as unexpected.""" - error = Exception("Something completely unexpected happened") - - reason, detail = classify_auth_error(error) - - assert reason == AuthOutcome.UNEXPECTED_ERROR - assert "unexpected" in detail.lower() - - def test_classifies_503_as_unreachable(self) -> None: - """HTTP 503 Service Unavailable is classified as unreachable.""" - error = Exception("HTTP 503: Service Unavailable") - - reason, detail = classify_auth_error(error) - - assert reason == AuthOutcome.UNREACHABLE - assert "503" in detail - - def test_classifies_429_as_unreachable(self) -> None: - """HTTP 429 Too Many Requests is classified as unreachable.""" - error = Exception("HTTP 429: Too Many Requests") - - reason, detail = classify_auth_error(error) - - assert reason == AuthOutcome.UNREACHABLE - assert "429" in detail - - def test_classifies_500_as_unexpected_error(self) -> None: - """HTTP 500 Server Error is classified as unexpected error.""" - error = Exception("HTTP 500: Internal Server Error") - - reason, detail = classify_auth_error(error) - - assert reason == AuthOutcome.UNEXPECTED_ERROR - assert "500" in detail - - def test_classifies_404_as_unexpected_error(self) -> None: - """HTTP 404 Not Found is classified as unexpected error (not auth failure).""" - error = Exception("HTTP 404: Not Found - endpoint does not exist") - - reason, detail = classify_auth_error(error) - - assert reason == AuthOutcome.UNEXPECTED_ERROR - assert "404" in detail - - def test_network_indicators_take_precedence_over_port_numbers(self) -> None: - """Network errors with port numbers don't get misclassified as HTTP errors.""" - # Port 443 should not be matched as HTTP 443 status code - error = Exception("Connection refused on port 443") - - reason, detail = classify_auth_error(error) - - assert reason == AuthOutcome.UNREACHABLE - assert "Connection refused" in detail - - -class TestGetAuthCallable: - """Tests for _get_auth_callable helper function.""" - - def test_returns_none_for_unknown_controller(self) -> None: - """Returns None for unknown controller types.""" - result = _get_auth_callable("UNKNOWN_CONTROLLER") - - assert result is None - - def test_returns_none_for_empty_string(self) -> None: - """Returns None for empty string controller type.""" - result = _get_auth_callable("") - - assert result is None - - def test_returns_none_for_iosxe(self) -> None: - """Returns None for IOSXE (no controller auth needed).""" - result = _get_auth_callable("IOSXE") - - assert result is None - - -class TestPreflightAuthCheck: - """Tests for preflight_auth_check main function.""" - - def test_returns_skipped_when_no_auth_adapter( - self, monkeypatch: MonkeyPatch - ) -> None: - """Returns skipped (not success) when no auth adapter is available.""" - monkeypatch.setenv("IOSXE_URL", "https://device.example.com") - - result = preflight_auth_check("IOSXE") - - assert result.success is True - assert result.reason == AuthOutcome.SKIPPED - assert "skipped" in result.detail.lower() - - def test_returns_success_when_adapters_not_installed( - self, monkeypatch: MonkeyPatch - ) -> None: - """Returns success when nac-test-pyats-common not installed.""" - monkeypatch.setenv("ACI_URL", "https://apic.example.com") - - with patch( - "nac_test.cli.validators.controller_auth._get_auth_callable", - return_value=None, - ): - result = preflight_auth_check("ACI") - - assert result.success is True - assert "skipped" in result.detail.lower() - - def test_returns_success_when_auth_succeeds(self, monkeypatch: MonkeyPatch) -> None: - """Returns success when authentication succeeds.""" - monkeypatch.setenv("ACI_URL", "https://apic.example.com") - - mock_auth = MagicMock(return_value="token123") - with patch( - "nac_test.cli.validators.controller_auth._get_auth_callable", - return_value=mock_auth, - ): - result = preflight_auth_check("ACI") - - assert result.success is True - assert result.reason == AuthOutcome.SUCCESS - assert result.controller_type == "ACI" - assert result.controller_url == "https://apic.example.com" - mock_auth.assert_called_once() - - def test_returns_failure_for_bad_credentials( - self, monkeypatch: MonkeyPatch - ) -> None: - """Returns failure when credentials are rejected.""" - monkeypatch.setenv("ACI_URL", "https://apic.example.com") - - mock_auth = MagicMock(side_effect=Exception("HTTP 401: Unauthorized")) - with patch( - "nac_test.cli.validators.controller_auth._get_auth_callable", - return_value=mock_auth, - ): - result = preflight_auth_check("ACI") - - assert result.success is False - assert result.reason == AuthOutcome.BAD_CREDENTIALS - assert "401" in result.detail - - def test_returns_failure_for_unreachable(self, monkeypatch: MonkeyPatch) -> None: - """Returns failure when controller is unreachable.""" - monkeypatch.setenv("SDWAN_URL", "https://sdwan.example.com") - - mock_auth = MagicMock(side_effect=Exception("Connection timed out")) - with patch( - "nac_test.cli.validators.controller_auth._get_auth_callable", - return_value=mock_auth, - ): - result = preflight_auth_check("SDWAN") - - assert result.success is False - assert result.reason == AuthOutcome.UNREACHABLE - assert result.controller_type == "SDWAN" - - def test_returns_success_when_missing_env_vars( - self, monkeypatch: MonkeyPatch - ) -> None: - """Returns success when env vars are missing (let real auth fail later).""" - monkeypatch.setenv("CC_URL", "https://catc.example.com") - - # ValueError is raised when env vars are missing - mock_auth = MagicMock( - side_effect=ValueError( - "Missing required environment variables: CC_USERNAME" - ) - ) - with patch( - "nac_test.cli.validators.controller_auth._get_auth_callable", - return_value=mock_auth, - ): - result = preflight_auth_check("CC") - - # Should succeed to let the actual auth call fail with proper error - assert result.success is True - assert "skipped" in result.detail.lower() - - def test_includes_controller_url_in_result(self, monkeypatch: MonkeyPatch) -> None: - """Auth result includes the controller URL for error messages.""" - monkeypatch.setenv("ACI_URL", "https://apic.lab.local") - - mock_auth = MagicMock(side_effect=Exception("HTTP 403: Forbidden")) - with patch( - "nac_test.cli.validators.controller_auth._get_auth_callable", - return_value=mock_auth, - ): - result = preflight_auth_check("ACI") - - assert result.controller_url == "https://apic.lab.local" - - def test_propagates_http_status_code(self, monkeypatch: MonkeyPatch) -> None: - """Auth result includes the HTTP status code from the error.""" - monkeypatch.setenv("ACI_URL", "https://apic.lab.local") - - mock_auth = MagicMock(side_effect=Exception("HTTP 403: Forbidden")) - with patch( - "nac_test.cli.validators.controller_auth._get_auth_callable", - return_value=mock_auth, - ): - result = preflight_auth_check("ACI") - - assert result.status_code == 403 - - def test_status_code_none_for_non_http_errors( - self, monkeypatch: MonkeyPatch - ) -> None: - """Auth result has None status_code for non-HTTP failures.""" - monkeypatch.setenv("SDWAN_URL", "https://sdwan.example.com") - - mock_auth = MagicMock(side_effect=Exception("Connection timed out")) - with patch( - "nac_test.cli.validators.controller_auth._get_auth_callable", - return_value=mock_auth, - ): - result = preflight_auth_check("SDWAN") - - assert result.status_code is None - - def test_handles_unknown_controller_type(self) -> None: - """Unknown controller types are handled gracefully (skipped).""" - result = preflight_auth_check("UNKNOWN_CONTROLLER") # type: ignore[arg-type] - - assert result.success is True - assert "skipped" in result.detail.lower() - - -class TestExtractHttpStatusCode: - """Tests for extract_http_status_code utility function.""" - - def test_extracts_401(self) -> None: - """Extracts 401 from an HTTP error message.""" - assert extract_http_status_code(Exception("HTTP 401: Unauthorized")) == 401 - - def test_extracts_403(self) -> None: - """Extracts 403 from an HTTP error message.""" - assert extract_http_status_code(Exception("HTTP 403: Forbidden")) == 403 - - def test_extracts_500(self) -> None: - """Extracts 500 from a server error message.""" - assert ( - extract_http_status_code(Exception("HTTP 500: Internal Server Error")) - == 500 - ) - - def test_returns_none_for_no_status_code(self) -> None: - """Returns None when no HTTP status code is present.""" - assert extract_http_status_code(Exception("Connection timed out")) is None - - def test_returns_none_for_non_http_message(self) -> None: - """Returns None for generic error messages.""" - assert extract_http_status_code(Exception("Something went wrong")) is None diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index e5547be4..ad5f2cb9 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -3,7 +3,6 @@ """Shared fixtures for unit tests.""" -import os from pathlib import Path from typing import Any, NamedTuple from unittest.mock import AsyncMock, Mock @@ -11,14 +10,12 @@ import pytest from _pytest.monkeypatch import MonkeyPatch -from nac_test.cli.validators.controller_auth import AuthCheckResult, AuthOutcome +from nac_test.core.controller_auth import AuthCheckResult, AuthOutcome from nac_test.pyats_core.constants import ( PYATS_GRACEFUL_DISCONNECT_WAIT_SECONDS, PYATS_POST_DISCONNECT_WAIT_SECONDS, ) -CONTROLLER_ENV_PREFIXES = ("ACI_", "SDWAN_", "CC_", "MERAKI_", "FMC_", "ISE_") - class PyATSTestDirs(NamedTuple): """Directory structure for PyATS orchestrator tests.""" @@ -78,17 +75,6 @@ def assert_connection_has_optimizations(connection: dict[str, Any]) -> None: ) -@pytest.fixture(autouse=True) -def clean_controller_env(monkeypatch: MonkeyPatch) -> None: - """Clear all controller-related environment variables. - - Ensures tests run in isolation regardless of the caller's shell environment. - """ - for key in list(os.environ.keys()): - if any(prefix in key for prefix in CONTROLLER_ENV_PREFIXES): - monkeypatch.delenv(key, raising=False) - - @pytest.fixture() def iosxe_controller_env(monkeypatch: MonkeyPatch) -> None: """Set up IOS-XE controller environment variables for testing. diff --git a/tests/unit/core/test_controller.py b/tests/unit/core/test_controller.py new file mode 100644 index 00000000..a6db39d2 --- /dev/null +++ b/tests/unit/core/test_controller.py @@ -0,0 +1,1142 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2025 Daniel Schmidt + +"""Tests for controller type detection utilities.""" + +import json +import logging + +import pytest + +from nac_test.core.constants import ENV_CONTROLLER_CONTEXT +from nac_test.core.controller import ( + CONTROLLER_REGISTRY, + CredentialSet, + IncompleteCredentials, + MultipleControllersFound, + NoCredentialsFound, + _find_credential_sets, + _format_multiple_credentials_error, + _format_no_credentials_error, + detect_controller_type, + format_resolution_error, + get_connection_params, + get_controller_context, + get_controller_url, + get_matched_credential_set, + resolve_controller, + should_verify_ssl, +) +from nac_test.core.types import AuthMethod, ControllerContext + +# ============================================================================= +# Test Data Constants +# ============================================================================= + +# Complete credential sets for all controllers with expected auth methods +CONTROLLER_CREDENTIALS: list[tuple[str, dict[str, str], str]] = [ + # (controller_type, env_vars, expected_auth_method) + ( + "ACI", + { + "ACI_URL": "https://apic.local", + "ACI_USERNAME": "admin", + "ACI_PASSWORD": "pass", + }, + "session", + ), + ( + "SDWAN", + {"SDWAN_URL": "https://sdwan.local", "SDWAN_API_TOKEN": "tok123"}, + "token", + ), + ( + "SDWAN", + { + "SDWAN_URL": "https://sdwan.local", + "SDWAN_USERNAME": "admin", + "SDWAN_PASSWORD": "pass", + }, + "session", + ), + ( + "CC", + {"CC_URL": "https://cc.local", "CC_USERNAME": "admin", "CC_PASSWORD": "pass"}, + "session", + ), + ( + "MERAKI", + { + "MERAKI_URL": "https://meraki.local", + "MERAKI_USERNAME": "admin", + "MERAKI_PASSWORD": "pass", + }, + "session", + ), + ( + "FMC", + { + "FMC_URL": "https://fmc.local", + "FMC_USERNAME": "admin", + "FMC_PASSWORD": "pass", + }, + "session", + ), + ( + "ISE", + { + "ISE_URL": "https://ise.local", + "ISE_USERNAME": "admin", + "ISE_PASSWORD": "pass", + }, + "session", + ), + ( + "IOSXE", + { + "IOSXE_URL": "https://iosxe.local", + "IOSXE_USERNAME": "admin", + "IOSXE_PASSWORD": "pass", + }, + "session", + ), + ( + "IOSXE", + { + "IOSXE_HOST": "192.168.1.1", + "IOSXE_USERNAME": "admin", + "IOSXE_PASSWORD": "pass", + }, + "session", + ), +] + +# Partial credential scenarios for error testing +PARTIAL_CREDENTIALS: list[tuple[str, dict[str, str], str]] = [ + # (expected_partial_controller, env_vars, description) + ( + "ACI", + {"ACI_URL": "https://apic.local", "ACI_USERNAME": "admin"}, + "missing password", + ), + ("SDWAN", {"SDWAN_URL": "https://sdwan.local"}, "URL only"), + ( + "IOSXE", + {"IOSXE_URL": "https://iosxe.local", "IOSXE_PASSWORD": "pass"}, + "missing username", + ), + ("CC", {"CC_URL": "https://cc.local"}, "URL only"), +] + + +class TestControllerResolutionContract: + """Contract tests verifying resolve_controller() and detect_controller_type() equivalence. + + These tests ensure the new API (resolve_controller) and deprecated API + (detect_controller_type) return equivalent results. The deprecated function + delegates to resolve_controller(), so these tests catch any drift. + + When Phase 3 removes detect_controller_type(), remove the deprecated assertions + but keep the resolve_controller tests as the primary coverage. + """ + + @pytest.mark.parametrize( + "controller_type,env_vars,expected_auth", + CONTROLLER_CREDENTIALS, + ids=[f"{c[0]}-{c[2]}" for c in CONTROLLER_CREDENTIALS], + ) + def test_success_both_apis_match( + self, + monkeypatch: pytest.MonkeyPatch, + controller_type: str, + env_vars: dict[str, str], + expected_auth: str, + ) -> None: + """Both APIs return same controller_type; resolve_controller includes auth_method.""" + for key, value in env_vars.items(): + monkeypatch.setenv(key, value) + + # New API: returns ControllerContext with type and auth + ctx = resolve_controller() + assert ctx.controller_type == controller_type + assert ctx.auth_method == expected_auth + + # Deprecated API: returns just controller_type (delegates to resolve_controller) + deprecated_result = detect_controller_type() + assert deprecated_result == ctx.controller_type, ( + f"Contract violation: detect_controller_type() returned {deprecated_result}, " + f"but resolve_controller().controller_type is {ctx.controller_type}" + ) + + def test_no_credentials_both_apis_raise( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """No credentials: resolve raises NoCredentialsFound, detect raises ValueError.""" + # New API: typed exception + with pytest.raises(NoCredentialsFound): + resolve_controller() + + # Deprecated API: ValueError for backwards compat + with pytest.raises(ValueError) as exc_info: + detect_controller_type() + assert "No controller credentials" in str(exc_info.value) + + @pytest.mark.parametrize( + "expected_partial,env_vars,scenario", + PARTIAL_CREDENTIALS, + ids=[f"{c[0]}-{c[2]}" for c in PARTIAL_CREDENTIALS], + ) + def test_incomplete_credentials_both_apis_raise( + self, + monkeypatch: pytest.MonkeyPatch, + expected_partial: str, + env_vars: dict[str, str], + scenario: str, + ) -> None: + """Partial credentials: resolve raises IncompleteCredentials, detect raises ValueError.""" + for key, value in env_vars.items(): + monkeypatch.setenv(key, value) + + # New API: typed exception with controller list + with pytest.raises(IncompleteCredentials) as exc_info: + resolve_controller() + assert expected_partial in exc_info.value.partial_controllers + + # Deprecated API: ValueError with same info + with pytest.raises(ValueError) as val_exc: + detect_controller_type() + assert f"{expected_partial}: incomplete credentials" in str(val_exc.value) + + def test_multiple_controllers_both_apis_raise( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Multiple complete controllers: both APIs raise appropriately.""" + # Set ACI credentials + monkeypatch.setenv("ACI_URL", "https://apic.local") + monkeypatch.setenv("ACI_USERNAME", "admin") + monkeypatch.setenv("ACI_PASSWORD", "pass") + # Set CC credentials + monkeypatch.setenv("CC_URL", "https://cc.local") + monkeypatch.setenv("CC_USERNAME", "admin") + monkeypatch.setenv("CC_PASSWORD", "pass") + + # New API: typed exception + with pytest.raises(MultipleControllersFound) as exc_info: + resolve_controller() + assert "ACI" in exc_info.value.controllers + assert "CC" in exc_info.value.controllers + + # Deprecated API: ValueError + with pytest.raises(ValueError) as val_exc: + detect_controller_type() + assert "Multiple controller credentials detected" in str(val_exc.value) + + +class TestGetControllerContext: + """Tests for get_controller_context() subprocess accessor. + + This function is used by PyATS subprocesses to retrieve the resolved + controller context. It reads from NAC_TEST_CONTROLLER_CONTEXT env var + (primary path) or falls back to detect_controller_type() (transitional). + """ + + def test_reads_from_env_var( + self, monkeypatch: pytest.MonkeyPatch, sdwan_context: ControllerContext + ) -> None: + """Primary path: deserializes from NAC_TEST_CONTROLLER_CONTEXT.""" + monkeypatch.setenv(ENV_CONTROLLER_CONTEXT, sdwan_context.to_json()) + result = get_controller_context() + assert result.controller_type == "SDWAN" + assert result.auth_method == "session" + + def test_fallback_to_detect_when_env_var_absent( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + """Transitional fallback: invokes detect_controller_type() with info log.""" + # Set controller credentials (fallback path will detect) + monkeypatch.setenv("ACI_URL", "https://apic.local") + monkeypatch.setenv("ACI_USERNAME", "admin") + monkeypatch.setenv("ACI_PASSWORD", "pass") + # NAC_TEST_CONTROLLER_CONTEXT deliberately not set + + with caplog.at_level(logging.INFO, logger="nac_test.core.controller"): + ctx = get_controller_context() + + assert ctx.controller_type == "ACI" + assert ctx.auth_method == "session" + assert "falling back to detect_controller_type" in caplog.text + + +class TestControllerContextSerialization: + """Contract tests for ControllerContext JSON serialization round-trip.""" + + def test_to_json_round_trip(self, aci_context: ControllerContext) -> None: + """ControllerContext serializes and deserializes correctly.""" + raw = aci_context.to_json() + restored = ControllerContext.from_json(raw) + assert restored == aci_context + + def test_from_json_ignores_unknown_fields(self) -> None: + """Unknown JSON fields are silently ignored (forward compatibility).""" + + raw = json.dumps( + { + "controller_type": "ACI", + "auth_method": "session", + "future_field": "some_value", + } + ) + ctx = ControllerContext.from_json(raw) + assert ctx.controller_type == "ACI" + assert ctx.auth_method == "session" + assert not hasattr(ctx, "future_field") + + def test_from_json_missing_field_raises(self) -> None: + """Missing required fields raise KeyError.""" + + raw = json.dumps({"controller_type": "ACI"}) + with pytest.raises(KeyError): + ControllerContext.from_json(raw) + + def test_from_json_malformed_raises(self) -> None: + """Malformed JSON raises ValueError.""" + + with pytest.raises(ValueError): + ControllerContext.from_json("not-json") + + +class TestFormatResolutionError: + """Tests for format_resolution_error().""" + + def test_format_no_credentials(self) -> None: + error = NoCredentialsFound("No creds") + result = format_resolution_error(error) + assert "No controller credentials" in result + + def test_format_multiple_controllers(self) -> None: + error = MultipleControllersFound(["ACI", "SDWAN"]) + result = format_resolution_error(error) + assert "ACI" in result + assert "SDWAN" in result + + def test_format_incomplete_credentials(self) -> None: + error = IncompleteCredentials(["ACI"]) + result = format_resolution_error(error) + assert "ACI" in result + assert "Incomplete" in result + + +class TestHelperFunctions: + """Test helper functions for credential detection.""" + + def test_find_credential_sets_complete( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test finding complete credential sets.""" + # Set complete credentials for CC + monkeypatch.setenv("CC_URL", "https://cc.example.com") + monkeypatch.setenv("CC_USERNAME", "admin") + monkeypatch.setenv("CC_PASSWORD", "password") + + complete, partial = _find_credential_sets() + + assert list(complete.keys()) == ["CC"] + assert partial == [] + assert "CC" in complete + assert complete["CC"].auth_method == "session" + + def test_find_credential_sets_partial( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test finding partial credential sets.""" + # Set partial credentials for FMC (missing password) + monkeypatch.setenv("FMC_URL", "https://fmc.example.com") + monkeypatch.setenv("FMC_USERNAME", "admin") + # No FMC_PASSWORD + + complete, partial = _find_credential_sets() + + assert complete == {} + assert "FMC" in partial + + def test_find_credential_sets_multiple_partial( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test finding multiple partial credential sets.""" + # Partial ISE credentials + monkeypatch.setenv("ISE_URL", "https://ise.example.com") + # Missing ISE_USERNAME and ISE_PASSWORD + + # Partial MERAKI credentials + monkeypatch.setenv("MERAKI_USERNAME", "meraki_user") + # Missing MERAKI_URL and MERAKI_PASSWORD + + complete, partial = _find_credential_sets() + + assert complete == {} + assert len(partial) == 2 + assert "ISE" in partial + assert "MERAKI" in partial + + def test_format_multiple_credentials_error(self) -> None: + """Test formatting error message for multiple controllers.""" + error_msg = _format_multiple_credentials_error(["ACI", "SDWAN", "CC"]) + + assert "Multiple controller credentials detected: ACI, SDWAN, CC" in error_msg + assert "To use ACI only:" in error_msg + # SDWAN has two credential sets, so all env vars from both sets appear + assert ( + "unset SDWAN_URL SDWAN_API_TOKEN SDWAN_USERNAME SDWAN_PASSWORD" in error_msg + ) + assert "CC_URL CC_USERNAME CC_PASSWORD" in error_msg + assert "To use SDWAN only:" in error_msg + assert ( + "unset ACI_URL ACI_USERNAME ACI_PASSWORD CC_URL CC_USERNAME CC_PASSWORD" + in error_msg + ) + assert "To use CC only:" in error_msg + assert "Use a separate shell session" in error_msg + + def test_format_no_credentials_error(self) -> None: + """Test formatting error message for no credentials.""" + error_msg = _format_no_credentials_error() + + assert "No controller credentials found in environment" in error_msg + assert "Controller credentials are required for ALL test types" in error_msg + assert "ACI:" in error_msg + assert "export ACI_URL=" in error_msg + assert "SDWAN:" in error_msg + assert "export SDWAN_URL=" in error_msg + assert "Example for ACI:" in error_msg + assert "Set credentials for only ONE controller type at a time" in error_msg + + +class TestControllerEdgeCases: + """Edge cases for controller detection: unicode, whitespace, case sensitivity, etc. + + These tests verify behavior with unusual input values that could break + credential detection or URL handling. + """ + + def test_case_sensitivity(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that environment variable names are case-sensitive.""" + # Set lowercase variables (should not be detected) + monkeypatch.setenv("aci_url", "https://apic.example.com") + monkeypatch.setenv("aci_username", "admin") + monkeypatch.setenv("aci_password", "password") + + with pytest.raises(ValueError) as exc_info: + detect_controller_type() + + assert "No controller credentials found" in str(exc_info.value) + + def test_special_characters_in_credentials( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test handling of special characters in credential values.""" + # Set credentials with special characters + monkeypatch.setenv("CC_URL", "https://cc.example.com:8443/path") + monkeypatch.setenv("CC_USERNAME", "user@domain.com") + monkeypatch.setenv("CC_PASSWORD", "p@$$w0rd!#$%^&*()") + + result = detect_controller_type() + assert result == "CC" + + def test_legacy_controller_type_ignored( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test that legacy CONTROLLER_TYPE variable is ignored.""" + # Set legacy CONTROLLER_TYPE (should be ignored) + monkeypatch.setenv("CONTROLLER_TYPE", "APIC") + + # Set actual SDWAN credentials + monkeypatch.setenv("SDWAN_URL", "https://vmanage.example.com") + monkeypatch.setenv("SDWAN_USERNAME", "admin") + monkeypatch.setenv("SDWAN_PASSWORD", "password") + + result = detect_controller_type() + assert ( + result == "SDWAN" + ) # Should use credential-based detection, not CONTROLLER_TYPE + + def test_mixed_complete_and_partial_credentials( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test scenario with one complete and one partial credential set.""" + # Complete FMC credentials + monkeypatch.setenv("FMC_URL", "https://fmc.example.com") + monkeypatch.setenv("FMC_USERNAME", "admin") + monkeypatch.setenv("FMC_PASSWORD", "password") + + # Partial ISE credentials (missing password) + monkeypatch.setenv("ISE_URL", "https://ise.example.com") + monkeypatch.setenv("ISE_USERNAME", "ise_admin") + + result = detect_controller_type() + assert result == "FMC" # Should detect the complete set + + def test_whitespace_trimming_in_values( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test that leading/trailing whitespace in values is handled correctly.""" + # Set credentials with extra whitespace (should still work) + monkeypatch.setenv("MERAKI_URL", " https://meraki.example.com ") + monkeypatch.setenv("MERAKI_USERNAME", " admin ") + monkeypatch.setenv("MERAKI_PASSWORD", " password ") + + result = detect_controller_type() + assert result == "MERAKI" + + def test_truly_empty_environment(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test with a completely empty environment.""" + # Clear all controller-related environment variables + for config in CONTROLLER_REGISTRY.values(): + for cred_set in config.credential_sets: + for var in cred_set.env_vars: + monkeypatch.delenv(var, raising=False) + + with pytest.raises(ValueError) as exc_info: + detect_controller_type() + + error_msg = str(exc_info.value) + assert "No controller credentials found" in error_msg + + def test_three_way_multiple_controllers( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test error message with three controllers configured.""" + # Set credentials for three controllers + monkeypatch.setenv("ACI_URL", "https://apic.example.com") + monkeypatch.setenv("ACI_USERNAME", "aci_user") + monkeypatch.setenv("ACI_PASSWORD", "aci_pass") + + monkeypatch.setenv("CC_URL", "https://cc.example.com") + monkeypatch.setenv("CC_USERNAME", "cc_user") + monkeypatch.setenv("CC_PASSWORD", "cc_pass") + + monkeypatch.setenv("ISE_URL", "https://ise.example.com") + monkeypatch.setenv("ISE_USERNAME", "ise_user") + monkeypatch.setenv("ISE_PASSWORD", "ise_pass") + + with pytest.raises(ValueError) as exc_info: + detect_controller_type() + + error_msg = str(exc_info.value) + assert "Multiple controller credentials detected: ACI, CC, ISE" in error_msg + assert "To use ACI only:" in error_msg + assert "To use CC only:" in error_msg + assert "To use ISE only:" in error_msg + + def test_unicode_in_credentials(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test handling of unicode characters in credentials.""" + # Set credentials with unicode characters + monkeypatch.setenv("ACI_URL", "https://apic.example.com") + monkeypatch.setenv("ACI_USERNAME", "用户名") # Chinese characters + monkeypatch.setenv("ACI_PASSWORD", "пароль") # Cyrillic characters + + result = detect_controller_type() + assert result == "ACI" + + def test_url_with_path_and_query(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test URL values with paths and query parameters.""" + monkeypatch.setenv( + "SDWAN_URL", "https://vmanage.example.com:8443/api/v1?test=true" + ) + monkeypatch.setenv("SDWAN_USERNAME", "admin") + monkeypatch.setenv("SDWAN_PASSWORD", "password") + + result = detect_controller_type() + assert result == "SDWAN" + + def test_iosxe_partial_and_sdwan_partial_are_both_reported( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Regression test: IOSXE_URL + IOSXE_PASSWORD (no IOSXE_USERNAME) combined + with SDWAN_URL must NOT detect IOSXE — both controllers should be reported + as partial, not complete. + + Before the fix that added IOSXE_USERNAME/IOSXE_PASSWORD to the IOSXE + credential sets, setting only IOSXE_URL was enough to satisfy detection, + so this combination incorrectly returned 'IOSXE' instead of raising. + """ + monkeypatch.setenv("IOSXE_URL", "https://iosxe.example.com") + monkeypatch.setenv("IOSXE_PASSWORD", "cisco123") + # IOSXE_USERNAME deliberately omitted — credential set must not be satisfied + monkeypatch.setenv("SDWAN_URL", "https://vmanage.example.com") + # No SDWAN credentials beyond URL + + with pytest.raises(ValueError) as exc_info: + detect_controller_type() + + error_msg = str(exc_info.value) + assert "Incomplete controller credentials detected" in error_msg + assert "IOSXE: incomplete credentials" in error_msg + assert "SDWAN: incomplete credentials" in error_msg + + def test_empty_string_handling(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that empty string values are treated as missing.""" + # Set ACI credentials with empty password + monkeypatch.setenv("ACI_URL", "https://apic.example.com") + monkeypatch.setenv("ACI_USERNAME", "admin") + monkeypatch.setenv("ACI_PASSWORD", "") # Empty string + + with pytest.raises(ValueError) as exc_info: + detect_controller_type() + + error_msg = str(exc_info.value) + assert "Incomplete controller credentials detected" in error_msg + assert "ACI: incomplete credentials" in error_msg + + def test_whitespace_handling(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that whitespace-only values are treated as missing.""" + # Set SDWAN credentials with whitespace-only password + monkeypatch.setenv("SDWAN_URL", "https://vmanage.example.com") + monkeypatch.setenv("SDWAN_USERNAME", "admin") + monkeypatch.setenv("SDWAN_PASSWORD", " ") # Only whitespace + + with pytest.raises(ValueError) as exc_info: + detect_controller_type() + + error_msg = str(exc_info.value) + assert "Incomplete controller credentials detected" in error_msg + assert "SDWAN: incomplete credentials" in error_msg + + def test_d2d_scenario_with_dummy_credentials( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test D2D scenario where controller credentials are still required.""" + # Set complete ACI credentials (even for D2D tests) + monkeypatch.setenv("ACI_URL", "https://dummy.controller.local") + monkeypatch.setenv("ACI_USERNAME", "dummy") + monkeypatch.setenv("ACI_PASSWORD", "dummy") + + # Also set device credentials (for D2D) + monkeypatch.setenv("IOSXE_USERNAME", "device_user") + monkeypatch.setenv("IOSXE_PASSWORD", "device_pass") + + result = detect_controller_type() + assert result == "ACI" # Controller type still detected + + +class TestIOSXEAlternativeURLEnvVar: + """Test IOSXE controller detection with alternative URL environment variables. + + IOSXE supports both IOSXE_URL and IOSXE_HOST as the URL environment variable + via separate credential sets. The first matching credential set wins. + """ + + def test_detect_iosxe_with_host(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test IOSXE detection with alternative IOSXE_HOST env var.""" + monkeypatch.setenv("IOSXE_HOST", "192.168.1.1") + monkeypatch.setenv("IOSXE_USERNAME", "admin") + monkeypatch.setenv("IOSXE_PASSWORD", "password") + + result = detect_controller_type() + assert result == "IOSXE" + + def test_iosxe_url_takes_precedence_over_host( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """When both IOSXE_URL and IOSXE_HOST are set, URL takes precedence.""" + monkeypatch.setenv("IOSXE_URL", "https://iosxe-url.example.com") + monkeypatch.setenv("IOSXE_HOST", "192.168.1.1") + monkeypatch.setenv("IOSXE_USERNAME", "admin") + monkeypatch.setenv("IOSXE_PASSWORD", "password") + + result = detect_controller_type() + assert result == "IOSXE" + + # Verify URL takes precedence in get_controller_url + url = get_controller_url("IOSXE") + assert url == "https://iosxe-url.example.com" + + def test_get_controller_url_returns_iosxe_host( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """get_controller_url returns IOSXE_HOST when IOSXE_URL is not set.""" + monkeypatch.setenv("IOSXE_HOST", "192.168.1.1") + + url = get_controller_url("IOSXE") + assert url == "192.168.1.1" + + def test_get_controller_url_raises_when_neither_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """get_controller_url raises KeyError when neither URL nor HOST is set.""" + # Neither IOSXE_URL nor IOSXE_HOST is set + + with pytest.raises(KeyError) as exc_info: + get_controller_url("IOSXE") + + assert "IOSXE_URL" in str(exc_info.value) + + def test_get_controller_url_strips_whitespace( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """get_controller_url strips leading/trailing whitespace.""" + monkeypatch.setenv("IOSXE_HOST", " 192.168.1.1 ") + + url = get_controller_url("IOSXE") + assert url == "192.168.1.1" + + def test_get_controller_url_empty_url_falls_back_to_host( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """get_controller_url uses IOSXE_HOST when IOSXE_URL is empty.""" + monkeypatch.setenv("IOSXE_URL", "") + monkeypatch.setenv("IOSXE_HOST", "192.168.1.1") + + url = get_controller_url("IOSXE") + assert url == "192.168.1.1" + + def test_get_controller_url_whitespace_url_falls_back_to_host( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """get_controller_url uses IOSXE_HOST when IOSXE_URL is only whitespace.""" + monkeypatch.setenv("IOSXE_URL", " ") + monkeypatch.setenv("IOSXE_HOST", "192.168.1.1") + + url = get_controller_url("IOSXE") + assert url == "192.168.1.1" + + +class TestGetControllerUrl: + """Tests for get_controller_url function.""" + + @pytest.mark.parametrize( + "controller_type,url_env_var", + [ + ("ACI", "ACI_URL"), + ("SDWAN", "SDWAN_URL"), + ("CC", "CC_URL"), + ("MERAKI", "MERAKI_URL"), + ("FMC", "FMC_URL"), + ("ISE", "ISE_URL"), + ("IOSXE", "IOSXE_URL"), + ], + ) + def test_get_controller_url_returns_correct_value( + self, monkeypatch: pytest.MonkeyPatch, controller_type: str, url_env_var: str + ) -> None: + """Test that get_controller_url returns the correct URL for each controller.""" + expected_url = f"https://{controller_type.lower()}.example.com" + monkeypatch.setenv(url_env_var, expected_url) + + result = get_controller_url(controller_type) + assert result == expected_url + + def test_get_controller_url_unknown_controller_fallback( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test fallback for unknown controller types.""" + monkeypatch.setenv("UNKNOWN_URL", "https://unknown.example.com") + + result = get_controller_url("UNKNOWN") + assert result == "https://unknown.example.com" + + def test_get_controller_url_unknown_controller_raises_when_not_set( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test that unknown controller type raises KeyError when env var not set.""" + with pytest.raises(KeyError) as exc_info: + get_controller_url("NONEXISTENT") + + assert "NONEXISTENT_URL" in str(exc_info.value) + + +class TestSDWANCredentialSets: + """Test SDWAN controller detection with multiple credential sets. + + SDWAN supports two credential methods: + 1. API Token (20.18+): SDWAN_URL + SDWAN_API_TOKEN (first — wins when both present) + 2. Username/Password: SDWAN_URL + SDWAN_USERNAME + SDWAN_PASSWORD + """ + + def test_detect_sdwan_with_api_token(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Test SDWAN detection with API token credentials.""" + monkeypatch.setenv("SDWAN_URL", "https://vmanage.example.com") + monkeypatch.setenv("SDWAN_API_TOKEN", "eyJhbGciOiJSUzI1NiJ9.test.sig") + + result = detect_controller_type() + assert result == "SDWAN" + + # Token set should be matched with auth_method="token" + cred = get_matched_credential_set("SDWAN") + assert cred is not None + assert cred.auth_method == "token" + assert cred.label == "API Token (20.18+)" + + def test_detect_sdwan_with_username_password( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Test SDWAN detection with traditional username/password.""" + monkeypatch.setenv("SDWAN_URL", "https://vmanage.example.com") + monkeypatch.setenv("SDWAN_USERNAME", "admin") + monkeypatch.setenv("SDWAN_PASSWORD", "password") + + result = detect_controller_type() + assert result == "SDWAN" + + # Password set should be matched with auth_method="session" + cred = get_matched_credential_set("SDWAN") + assert cred is not None + assert cred.auth_method == "session" + assert cred.label == "Username/Password" + + def test_api_token_takes_priority(self, monkeypatch: pytest.MonkeyPatch) -> None: + """When both credential sets are satisfied, token set wins (listed first).""" + monkeypatch.setenv("SDWAN_URL", "https://vmanage.example.com") + monkeypatch.setenv("SDWAN_API_TOKEN", "eyJhbGciOiJSUzI1NiJ9.test.sig") + monkeypatch.setenv("SDWAN_USERNAME", "admin") + monkeypatch.setenv("SDWAN_PASSWORD", "password") + + # Should still detect exactly one SDWAN (not duplicate) + result = detect_controller_type() + assert result == "SDWAN" + + # Token set wins because it's listed first + cred = get_matched_credential_set("SDWAN") + assert cred is not None + assert cred.auth_method == "token" + + def test_partial_token_set_falls_back_to_password( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """When SDWAN_API_TOKEN is missing but username/password present, detect SDWAN.""" + monkeypatch.setenv("SDWAN_URL", "https://vmanage.example.com") + # No SDWAN_API_TOKEN + monkeypatch.setenv("SDWAN_USERNAME", "admin") + monkeypatch.setenv("SDWAN_PASSWORD", "password") + + result = detect_controller_type() + assert result == "SDWAN" + + # Password set matched because token set was incomplete + cred = get_matched_credential_set("SDWAN") + assert cred is not None + assert cred.auth_method == "session" + + def test_empty_api_token_falls_back_to_password( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Empty SDWAN_API_TOKEN should not satisfy the token credential set.""" + monkeypatch.setenv("SDWAN_URL", "https://vmanage.example.com") + monkeypatch.setenv("SDWAN_API_TOKEN", "") + monkeypatch.setenv("SDWAN_USERNAME", "admin") + monkeypatch.setenv("SDWAN_PASSWORD", "password") + + result = detect_controller_type() + assert result == "SDWAN" + + # Should fall back to session auth + cred = get_matched_credential_set("SDWAN") + assert cred is not None + assert cred.auth_method == "session" + + def test_url_only_is_partial(self, monkeypatch: pytest.MonkeyPatch) -> None: + """SDWAN_URL alone (no token, no username/password) is partial.""" + monkeypatch.setenv("SDWAN_URL", "https://vmanage.example.com") + + with pytest.raises(ValueError) as exc_info: + detect_controller_type() + + error_msg = str(exc_info.value) + assert "Incomplete controller credentials detected" in error_msg + assert "SDWAN: incomplete credentials" in error_msg + assert "API Token (20.18+)" in error_msg + assert "Username/Password" in error_msg + + def test_get_matched_credential_set_before_detection(self) -> None: + """get_matched_credential_set returns None before detect_controller_type runs.""" + assert get_matched_credential_set("SDWAN") is None + + def test_credential_set_auth_method_default(self) -> None: + """CredentialSet.auth_method defaults to 'session'.""" + cs = CredentialSet( + fields={"url": "X_URL", "username": "X_USER", "password": "X_PASS"}, + label="test", + ) + assert cs.auth_method == "session" + + def test_aci_matched_credential_set(self, monkeypatch: pytest.MonkeyPatch) -> None: + """ACI detection stores matched credential set with session auth.""" + monkeypatch.setenv("ACI_URL", "https://apic.example.com") + monkeypatch.setenv("ACI_USERNAME", "admin") + monkeypatch.setenv("ACI_PASSWORD", "password") + + detect_controller_type() + + cred = get_matched_credential_set("ACI") + assert cred is not None + assert cred.auth_method == "session" + assert cred.label == "Username/Password" + + +class TestGetControllerUrlSDWAN: + """Tests for get_controller_url with multi-credential-set controllers (SDWAN).""" + + def test_sdwan_does_not_return_token_when_url_empty( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """get_controller_url raises KeyError when SDWAN_URL is empty, not returning token.""" + monkeypatch.setenv("SDWAN_URL", "") + monkeypatch.setenv("SDWAN_API_TOKEN", "eyJhbGciOiJSUzI1NiJ9.test.sig") + + with pytest.raises(KeyError) as exc_info: + get_controller_url("SDWAN") + + assert "SDWAN_URL" in str(exc_info.value) + + def test_sdwan_does_not_return_token_when_url_whitespace( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """get_controller_url raises KeyError when SDWAN_URL is whitespace-only.""" + monkeypatch.setenv("SDWAN_URL", " ") + monkeypatch.setenv("SDWAN_API_TOKEN", "some-token") + + with pytest.raises(KeyError) as exc_info: + get_controller_url("SDWAN") + + assert "SDWAN_URL" in str(exc_info.value) + + def test_sdwan_does_not_return_username_when_url_missing( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """get_controller_url raises KeyError, not returning username/password vars.""" + monkeypatch.setenv("SDWAN_USERNAME", "admin") + monkeypatch.setenv("SDWAN_PASSWORD", "password") + + with pytest.raises(KeyError) as exc_info: + get_controller_url("SDWAN") + + assert "SDWAN_URL" in str(exc_info.value) + + +class TestGetConnectionParams: + """Tests for get_connection_params().""" + + def test_aci_session(self, monkeypatch: pytest.MonkeyPatch) -> None: + """ACI session auth resolves url/username/password by kind.""" + monkeypatch.setenv("ACI_URL", "https://apic.example.com") + monkeypatch.setenv("ACI_USERNAME", "admin") + monkeypatch.setenv("ACI_PASSWORD", "password") + + params = get_connection_params("ACI", AuthMethod.SESSION) + + assert params == { + "url": "https://apic.example.com", + "username": "admin", + "password": "password", + } + + def test_sdwan_token(self, monkeypatch: pytest.MonkeyPatch) -> None: + """SDWAN token auth resolves url/token by kind.""" + monkeypatch.setenv("SDWAN_URL", "https://vmanage.example.com") + monkeypatch.setenv("SDWAN_API_TOKEN", "abc.def.ghi") + + params = get_connection_params("SDWAN", AuthMethod.TOKEN) + + assert params == { + "url": "https://vmanage.example.com", + "token": "abc.def.ghi", + } + + def test_sdwan_session(self, monkeypatch: pytest.MonkeyPatch) -> None: + """SDWAN session auth resolves url/username/password by kind.""" + monkeypatch.setenv("SDWAN_URL", "https://vmanage.example.com") + monkeypatch.setenv("SDWAN_USERNAME", "admin") + monkeypatch.setenv("SDWAN_PASSWORD", "password") + + params = get_connection_params("SDWAN", AuthMethod.SESSION) + + assert params == { + "url": "https://vmanage.example.com", + "username": "admin", + "password": "password", + } + + def test_cc_session(self, monkeypatch: pytest.MonkeyPatch) -> None: + """CC session auth resolves url/username/password by kind.""" + monkeypatch.setenv("CC_URL", "https://dnac.example.com") + monkeypatch.setenv("CC_USERNAME", "admin") + monkeypatch.setenv("CC_PASSWORD", "password") + + params = get_connection_params("CC", AuthMethod.SESSION) + + assert params == { + "url": "https://dnac.example.com", + "username": "admin", + "password": "password", + } + + def test_unknown_controller_type_raises_key_error(self) -> None: + """Unknown controller_type raises KeyError.""" + with pytest.raises(KeyError): + get_connection_params("BOGUS", AuthMethod.SESSION) + + def test_unmatched_auth_method_raises_value_error(self) -> None: + """auth_method with no matching credential set raises ValueError.""" + with pytest.raises(ValueError, match="auth_method"): + get_connection_params("ACI", AuthMethod.TOKEN) + + def test_missing_env_vars_raises_value_error( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Unset env vars raise ValueError listing the missing var names.""" + monkeypatch.setenv("ACI_URL", "https://apic.example.com") + monkeypatch.delenv("ACI_USERNAME", raising=False) + monkeypatch.delenv("ACI_PASSWORD", raising=False) + + with pytest.raises(ValueError) as exc_info: + get_connection_params("ACI", AuthMethod.SESSION) + + assert "ACI_USERNAME" in str(exc_info.value) + assert "ACI_PASSWORD" in str(exc_info.value) + + @pytest.mark.parametrize( + "empty_value", + ["", " "], + ids=["empty-string", "whitespace-only"], + ) + def test_empty_env_vars_treated_as_missing( + self, monkeypatch: pytest.MonkeyPatch, empty_value: str + ) -> None: + """Empty/whitespace-only env vars are treated as missing in get_connection_params. + + Ensures that os.environ vars set to '' or whitespace trigger the same + ValueError as completely unset vars — guards against CI environments or + docker-compose files that export VAR= with no value. + """ + monkeypatch.setenv("ACI_URL", "https://apic.example.com") + monkeypatch.setenv("ACI_USERNAME", "admin") + monkeypatch.setenv("ACI_PASSWORD", empty_value) + + with pytest.raises(ValueError) as exc_info: + get_connection_params("ACI", AuthMethod.SESSION) + + assert "ACI_PASSWORD" in str(exc_info.value) + + def test_meraki_session(self, monkeypatch: pytest.MonkeyPatch) -> None: + """MERAKI session auth resolves url/username/password by kind.""" + monkeypatch.setenv("MERAKI_URL", "https://meraki.example.com") + monkeypatch.setenv("MERAKI_USERNAME", "admin") + monkeypatch.setenv("MERAKI_PASSWORD", "password") + + params = get_connection_params("MERAKI", AuthMethod.SESSION) + + assert params == { + "url": "https://meraki.example.com", + "username": "admin", + "password": "password", + } + + def test_env_vars_and_kinds_derived_from_fields(self) -> None: + """env_vars/kinds are computed from `fields`, so they can never mismatch.""" + cs = CredentialSet( + fields={"url": "BAD_URL", "username": "BAD_USERNAME"}, label="Broken" + ) + assert cs.env_vars == ("BAD_URL", "BAD_USERNAME") + assert cs.kinds == ("url", "username") + + def test_iosxe_host_variant_resolves_when_url_unset( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """IOSXE_HOST alone (no IOSXE_URL) resolves via the Host credential set. + + Both IOSXE_URL and IOSXE_HOST share auth_method="session", so the first + fully-satisfied candidate must win - not just the first one in order. + """ + monkeypatch.delenv("IOSXE_URL", raising=False) + monkeypatch.setenv("IOSXE_HOST", "192.168.1.1") + monkeypatch.setenv("IOSXE_USERNAME", "admin") + monkeypatch.setenv("IOSXE_PASSWORD", "password") + + params = get_connection_params("IOSXE", AuthMethod.SESSION) + + assert params == { + "url": "192.168.1.1", + "username": "admin", + "password": "password", + } + + def test_iosxe_reports_url_variant_missing_vars_when_nothing_configured( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """With neither variant configured, the first (URL) set's vars are reported.""" + monkeypatch.delenv("IOSXE_URL", raising=False) + monkeypatch.delenv("IOSXE_HOST", raising=False) + monkeypatch.delenv("IOSXE_USERNAME", raising=False) + monkeypatch.delenv("IOSXE_PASSWORD", raising=False) + + with pytest.raises(ValueError) as exc_info: + get_connection_params("IOSXE", AuthMethod.SESSION) + + assert "IOSXE_URL" in str(exc_info.value) + + def test_iosxe_reports_host_variant_missing_vars_when_partially_configured( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """IOSXE_HOST set but username/password missing reports IOSXE_HOST vars, + + not IOSXE_URL - the caller never touched the URL variant, so the + error must point at the variant they actually started configuring. + """ + monkeypatch.delenv("IOSXE_URL", raising=False) + monkeypatch.setenv("IOSXE_HOST", "192.168.1.1") + monkeypatch.delenv("IOSXE_USERNAME", raising=False) + monkeypatch.delenv("IOSXE_PASSWORD", raising=False) + + with pytest.raises(ValueError) as exc_info: + get_connection_params("IOSXE", AuthMethod.SESSION) + + error_msg = str(exc_info.value) + assert "IOSXE_USERNAME" in error_msg + assert "IOSXE_PASSWORD" in error_msg + assert "IOSXE_URL" not in error_msg + + +class TestShouldVerifySsl: + """Tests for should_verify_ssl().""" + + def test_defaults_false_when_unset(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Unset env var defaults to False (skip verify), matching prior adapter behavior.""" + monkeypatch.delenv("ACI_INSECURE", raising=False) + + assert should_verify_ssl("ACI") is False + + def test_defaults_false_when_empty(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Empty string is treated the same as unset.""" + monkeypatch.setenv("CC_INSECURE", "") + + assert should_verify_ssl("CC") is False + + @pytest.mark.parametrize("raw", ["True", "true", "1", "yes", "YES"]) + def test_insecure_truthy_means_no_verify( + self, monkeypatch: pytest.MonkeyPatch, raw: str + ) -> None: + """When INSECURE env var is truthy, should_verify_ssl returns False.""" + monkeypatch.setenv("SDWAN_INSECURE", raw) + + assert should_verify_ssl("SDWAN") is False + + @pytest.mark.parametrize("raw", ["False", "false", "0", "no"]) + def test_insecure_falsy_means_verify( + self, monkeypatch: pytest.MonkeyPatch, raw: str + ) -> None: + """When INSECURE env var is falsy, should_verify_ssl returns True (verify).""" + monkeypatch.setenv("SDWAN_INSECURE", raw) + + assert should_verify_ssl("SDWAN") is True + + def test_custom_default_used_when_unset( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The `default` param controls the unset fallback.""" + monkeypatch.delenv("ISE_INSECURE", raising=False) + + assert should_verify_ssl("ISE", default=True) is True + + def test_unknown_controller_type_raises_key_error(self) -> None: + """Unknown controller_type raises KeyError.""" + with pytest.raises(KeyError): + should_verify_ssl("BOGUS") diff --git a/tests/unit/core/test_controller_auth.py b/tests/unit/core/test_controller_auth.py new file mode 100644 index 00000000..e84ce4f7 --- /dev/null +++ b/tests/unit/core/test_controller_auth.py @@ -0,0 +1,242 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2025 Daniel Schmidt +"""Unit tests for pre-flight controller authentication. + +Tests verify the business logic of the pre-flight auth check, +ensuring authentication failures are identified and classified appropriately. +""" + +from _pytest.monkeypatch import MonkeyPatch +from pytest_mock import MockerFixture + +from nac_test.core.controller_auth import ( + CONTROLLER_REGISTRY, + AuthOutcome, + _get_auth_callable, + preflight_auth_check, +) +from nac_test.core.types import AuthMethod, ControllerContext + + +class TestControllerRegistry: + """Tests for CONTROLLER_REGISTRY configuration.""" + + def test_registry_covers_all_supported_controllers(self) -> None: + """Registry includes all supported controller types with valid configs.""" + # After consolidation: CONTROLLER_REGISTRY now includes ALL controllers + expected_controllers = {"ACI", "SDWAN", "CC", "MERAKI", "FMC", "ISE", "IOSXE"} + assert set(CONTROLLER_REGISTRY.keys()) == expected_controllers + + for controller_type, config in CONTROLLER_REGISTRY.items(): + assert config.display_name, f"{controller_type} missing display_name" + assert config.url_env_var, f"{controller_type} missing url_env_var" + assert config.env_var_prefix, f"{controller_type} missing env_var_prefix" + + +class TestGetAuthCallable: + """Tests for _get_auth_callable helper function.""" + + def test_returns_none_for_unknown_controller(self) -> None: + """Returns None for unknown controller types.""" + result = _get_auth_callable("UNKNOWN_CONTROLLER") + + assert result is None + + def test_returns_none_for_empty_string(self) -> None: + """Returns None for empty string controller type.""" + result = _get_auth_callable("") + + assert result is None + + def test_returns_none_for_iosxe(self) -> None: + """Returns None for IOSXE (no controller auth needed).""" + result = _get_auth_callable("IOSXE") + + assert result is None + + +class TestPreflightAuthCheck: + """Tests for preflight_auth_check main function.""" + + def test_returns_skipped_when_no_auth_adapter( + self, monkeypatch: MonkeyPatch, iosxe_context: ControllerContext + ) -> None: + """Returns skipped (not success) when no auth adapter is available.""" + monkeypatch.setenv("IOSXE_URL", "https://device.example.com") + + result = preflight_auth_check(iosxe_context) + + assert result.success is True + assert result.reason == AuthOutcome.SKIPPED + assert "skipped" in result.detail.lower() + + def test_returns_success_when_adapters_not_installed( + self, + monkeypatch: MonkeyPatch, + aci_context: ControllerContext, + mocker: MockerFixture, + ) -> None: + """Returns success when nac-test-pyats-common not installed.""" + monkeypatch.setenv("ACI_URL", "https://apic.example.com") + + mocker.patch( + "nac_test.core.controller_auth._get_auth_callable", + return_value=None, + ) + result = preflight_auth_check(aci_context) + + assert result.success is True + assert "skipped" in result.detail.lower() + + def test_returns_success_when_auth_succeeds( + self, + monkeypatch: MonkeyPatch, + aci_context: ControllerContext, + mocker: MockerFixture, + ) -> None: + """Returns success when authentication succeeds.""" + monkeypatch.setenv("ACI_URL", "https://apic.example.com") + + mock_auth = mocker.MagicMock(return_value="token123") + mocker.patch( + "nac_test.core.controller_auth._get_auth_callable", + return_value=mock_auth, + ) + result = preflight_auth_check(aci_context) + + assert result.success is True + assert result.reason == AuthOutcome.SUCCESS + assert result.controller_type == "ACI" + assert result.controller_url == "https://apic.example.com" + mock_auth.assert_called_once() + + def test_returns_failure_for_bad_credentials( + self, + monkeypatch: MonkeyPatch, + aci_context: ControllerContext, + mocker: MockerFixture, + ) -> None: + """Returns failure when credentials are rejected.""" + monkeypatch.setenv("ACI_URL", "https://apic.example.com") + + mock_auth = mocker.MagicMock(side_effect=Exception("HTTP 401: Unauthorized")) + mocker.patch( + "nac_test.core.controller_auth._get_auth_callable", + return_value=mock_auth, + ) + result = preflight_auth_check(aci_context) + + assert result.success is False + assert result.reason == AuthOutcome.BAD_CREDENTIALS + assert "401" in result.detail + + def test_returns_failure_for_unreachable( + self, + monkeypatch: MonkeyPatch, + sdwan_context: ControllerContext, + mocker: MockerFixture, + ) -> None: + """Returns failure when controller is unreachable.""" + monkeypatch.setenv("SDWAN_URL", "https://sdwan.example.com") + + mock_auth = mocker.MagicMock(side_effect=Exception("Connection timed out")) + mocker.patch( + "nac_test.core.controller_auth._get_auth_callable", + return_value=mock_auth, + ) + result = preflight_auth_check(sdwan_context) + + assert result.success is False + assert result.reason == AuthOutcome.UNREACHABLE + assert result.controller_type == "SDWAN" + + def test_returns_success_when_missing_env_vars( + self, + monkeypatch: MonkeyPatch, + cc_context: ControllerContext, + mocker: MockerFixture, + ) -> None: + """Returns success when env vars are missing (let real auth fail later).""" + monkeypatch.setenv("CC_URL", "https://catc.example.com") + + # ValueError is raised when env vars are missing + mock_auth = mocker.MagicMock( + side_effect=ValueError( + "Missing required environment variables: CC_USERNAME" + ) + ) + mocker.patch( + "nac_test.core.controller_auth._get_auth_callable", + return_value=mock_auth, + ) + result = preflight_auth_check(cc_context) + + # Should succeed to let the actual auth call fail with proper error + assert result.success is True + assert "skipped" in result.detail.lower() + + def test_includes_controller_url_in_result( + self, + monkeypatch: MonkeyPatch, + aci_context: ControllerContext, + mocker: MockerFixture, + ) -> None: + """Auth result includes the controller URL for error messages.""" + monkeypatch.setenv("ACI_URL", "https://apic.lab.local") + + mock_auth = mocker.MagicMock(side_effect=Exception("HTTP 403: Forbidden")) + mocker.patch( + "nac_test.core.controller_auth._get_auth_callable", + return_value=mock_auth, + ) + result = preflight_auth_check(aci_context) + + assert result.controller_url == "https://apic.lab.local" + + def test_propagates_http_status_code( + self, + monkeypatch: MonkeyPatch, + aci_context: ControllerContext, + mocker: MockerFixture, + ) -> None: + """Auth result includes the HTTP status code from the error.""" + monkeypatch.setenv("ACI_URL", "https://apic.lab.local") + + mock_auth = mocker.MagicMock(side_effect=Exception("HTTP 403: Forbidden")) + mocker.patch( + "nac_test.core.controller_auth._get_auth_callable", + return_value=mock_auth, + ) + result = preflight_auth_check(aci_context) + + assert result.status_code == 403 + + def test_status_code_none_for_non_http_errors( + self, + monkeypatch: MonkeyPatch, + sdwan_context: ControllerContext, + mocker: MockerFixture, + ) -> None: + """Auth result has None status_code for non-HTTP failures.""" + monkeypatch.setenv("SDWAN_URL", "https://sdwan.example.com") + + mock_auth = mocker.MagicMock(side_effect=Exception("Connection timed out")) + mocker.patch( + "nac_test.core.controller_auth._get_auth_callable", + return_value=mock_auth, + ) + result = preflight_auth_check(sdwan_context) + + assert result.status_code is None + + def test_handles_unknown_controller_type(self) -> None: + """Unknown controller types are handled gracefully (skipped).""" + result = preflight_auth_check( + ControllerContext( + controller_type="UNKNOWN_CONTROLLER", # type: ignore[arg-type] + auth_method=AuthMethod.SESSION, + ) + ) + + assert result.success is True + assert "skipped" in result.detail.lower() diff --git a/tests/unit/core/test_error_classification.py b/tests/unit/core/test_error_classification.py new file mode 100644 index 00000000..d1eb5bf2 --- /dev/null +++ b/tests/unit/core/test_error_classification.py @@ -0,0 +1,135 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2025 Daniel Schmidt +"""Unit tests for error classification utilities.""" + +from nac_test.core.controller_auth import AuthOutcome, classify_auth_error +from nac_test.core.error_classification import extract_http_status_code + + +class TestExtractHttpStatusCode: + """Tests for extract_http_status_code utility function.""" + + def test_extracts_401(self) -> None: + """Extracts 401 from an HTTP error message.""" + assert extract_http_status_code(Exception("HTTP 401: Unauthorized")) == 401 + + def test_extracts_403(self) -> None: + """Extracts 403 from an HTTP error message.""" + assert extract_http_status_code(Exception("HTTP 403: Forbidden")) == 403 + + def test_extracts_500(self) -> None: + """Extracts 500 from a server error message.""" + assert ( + extract_http_status_code(Exception("HTTP 500: Internal Server Error")) + == 500 + ) + + def test_returns_none_for_no_status_code(self) -> None: + """Returns None when no HTTP status code is present.""" + assert extract_http_status_code(Exception("Connection timed out")) is None + + def test_returns_none_for_non_http_message(self) -> None: + """Returns None for generic error messages.""" + assert extract_http_status_code(Exception("Something went wrong")) is None + + +class TestClassifyAuthError: + """Tests for classify_auth_error helper function.""" + + def test_classifies_401_as_bad_credentials(self) -> None: + """HTTP 401 errors are classified as bad credentials.""" + error = Exception("HTTP 401: Unauthorized") + + reason, detail = classify_auth_error(error) + + assert reason == AuthOutcome.BAD_CREDENTIALS + assert detail == "HTTP 401: Unauthorized" + + def test_classifies_403_as_bad_credentials(self) -> None: + """HTTP 403 errors are classified as bad credentials.""" + error = Exception("HTTP 403: Forbidden - insufficient privileges") + + reason, detail = classify_auth_error(error) + + assert reason == AuthOutcome.BAD_CREDENTIALS + assert detail == "HTTP 403: Forbidden" + + def test_classifies_timeout_as_unreachable(self) -> None: + """Timeout errors are classified as unreachable.""" + error = Exception("Connection timed out after 30 seconds") + + reason, detail = classify_auth_error(error) + + assert reason == AuthOutcome.UNREACHABLE + assert "timed out" in detail.lower() + + def test_classifies_connection_refused_as_unreachable(self) -> None: + """Connection refused errors are classified as unreachable.""" + error = Exception("Connection refused on port 443") + + reason, detail = classify_auth_error(error) + + assert reason == AuthOutcome.UNREACHABLE + + def test_classifies_dns_failure_as_unreachable(self) -> None: + """DNS resolution failures are classified as unreachable.""" + error = Exception("Name or service not known: apic.example.com") + + reason, detail = classify_auth_error(error) + + assert reason == AuthOutcome.UNREACHABLE + + def test_classifies_unknown_as_unexpected_error(self) -> None: + """Unknown errors are classified as unexpected.""" + error = Exception("Something completely unexpected happened") + + reason, detail = classify_auth_error(error) + + assert reason == AuthOutcome.UNEXPECTED_ERROR + assert "unexpected" in detail.lower() + + def test_classifies_503_as_unreachable(self) -> None: + """HTTP 503 Service Unavailable is classified as unreachable.""" + error = Exception("HTTP 503: Service Unavailable") + + reason, detail = classify_auth_error(error) + + assert reason == AuthOutcome.UNREACHABLE + assert "503" in detail + + def test_classifies_429_as_unreachable(self) -> None: + """HTTP 429 Too Many Requests is classified as unreachable.""" + error = Exception("HTTP 429: Too Many Requests") + + reason, detail = classify_auth_error(error) + + assert reason == AuthOutcome.UNREACHABLE + assert "429" in detail + + def test_classifies_500_as_unexpected_error(self) -> None: + """HTTP 500 Server Error is classified as unexpected error.""" + error = Exception("HTTP 500: Internal Server Error") + + reason, detail = classify_auth_error(error) + + assert reason == AuthOutcome.UNEXPECTED_ERROR + assert "500" in detail + + def test_classifies_404_as_unexpected_error(self) -> None: + """HTTP 404 Not Found is classified as unexpected error (not auth failure).""" + error = Exception("HTTP 404: Not Found - endpoint does not exist") + + reason, detail = classify_auth_error(error) + + assert reason == AuthOutcome.UNEXPECTED_ERROR + assert "404" in detail + + def test_network_indicators_take_precedence_over_port_numbers(self) -> None: + """Network errors with port numbers don't get misclassified as HTTP errors.""" + # Port 443 should not be matched as HTTP 443 status code + error = Exception("Connection refused on port 443") + + reason, detail = classify_auth_error(error) + + assert reason == AuthOutcome.UNREACHABLE + assert "Connection refused" in detail diff --git a/tests/unit/pyats_core/common/test_controller_defaults_integration.py b/tests/unit/pyats_core/common/test_controller_defaults_integration.py index d19df680..2ae10e8e 100644 --- a/tests/unit/pyats_core/common/test_controller_defaults_integration.py +++ b/tests/unit/pyats_core/common/test_controller_defaults_integration.py @@ -13,7 +13,7 @@ import pytest -from nac_test.utils.controller import CONTROLLER_REGISTRY, get_defaults_prefix +from nac_test.core.controller import CONTROLLER_REGISTRY, get_defaults_prefix class TestControllerDefaultsPrefixMapping: diff --git a/tests/unit/pyats_core/test_orchestrator_config_error.py b/tests/unit/pyats_core/test_orchestrator_config_error.py index 190ed89c..397fe230 100644 --- a/tests/unit/pyats_core/test_orchestrator_config_error.py +++ b/tests/unit/pyats_core/test_orchestrator_config_error.py @@ -3,11 +3,13 @@ """Tests for PyATSOrchestrator handling of SubprocessRunner init failures.""" +import os from pathlib import Path from unittest.mock import MagicMock, patch import pytest +from nac_test.core.constants import ENV_CONTROLLER_CONTEXT from nac_test.pyats_core.orchestrator import PyATSOrchestrator from ..conftest import PyATSTestDirs @@ -59,7 +61,6 @@ def test_subprocess_runner_init_error_returns_from_error_results( "discover_pyats_tests", return_value=mock_discovery_result, ), - patch.object(orchestrator, "validate_environment"), patch( "nac_test.pyats_core.execution.subprocess_runner.Path.write_text", side_effect=OSError("disk full"), @@ -82,3 +83,31 @@ def test_subprocess_runner_init_error_returns_from_error_results( assert "disk full" in result.d2d.reason else: assert result.d2d is None + + +class TestOrchestratorControllerContextEnvVar: + """Tests for PyATSOrchestrator controller context handling.""" + + def test_orchestrator_populates_controller_context_env_var( + self, + aci_controller_env: None, + pyats_test_dirs: PyATSTestDirs, + ) -> None: + """PyATSOrchestrator stores controller_context without polluting os.environ.""" + # Clear any existing context from prior tests + os.environ.pop(ENV_CONTROLLER_CONTEXT, None) + + orchestrator = PyATSOrchestrator( + data_paths=[pyats_test_dirs.output_dir.parent / "data"], + test_dir=pyats_test_dirs.test_dir, + output_dir=pyats_test_dirs.output_dir, + ) + + # __init__ should NOT set the env var (moved to subprocess launch) + assert ENV_CONTROLLER_CONTEXT not in os.environ + + # But the orchestrator should have the context stored + assert orchestrator.controller_context is not None + assert orchestrator.controller_context.controller_type == "ACI" + assert orchestrator.controller_context.auth_method == "session" + assert orchestrator.controller_type == "ACI" diff --git a/tests/unit/test_combined_orchestrator_controller.py b/tests/unit/test_combined_orchestrator_controller.py index bd08a5de..e76d4246 100644 --- a/tests/unit/test_combined_orchestrator_controller.py +++ b/tests/unit/test_combined_orchestrator_controller.py @@ -6,12 +6,11 @@ from pathlib import Path from unittest.mock import MagicMock, patch -import pytest from _pytest.monkeypatch import MonkeyPatch -from nac_test.cli.validators.controller_auth import AuthCheckResult, AuthOutcome from nac_test.combined_orchestrator import CombinedOrchestrator -from nac_test.core.types import PyATSResults +from nac_test.core.controller_auth import AuthCheckResult, AuthOutcome +from nac_test.core.types import ControllerContext, PyATSResults from nac_test.utils.logging import DEFAULT_LOGLEVEL from tests.unit.conftest import AUTH_SUCCESS @@ -29,10 +28,6 @@ def test(self): class TestCombinedOrchestratorController: """Tests for CombinedOrchestrator controller detection.""" - @pytest.fixture(autouse=True) - def _clean_env(self, clean_controller_env: None) -> None: - """Apply shared clean_controller_env fixture to all tests in this class.""" - def test_controller_type_is_none_after_init( self, tmp_path: Path, monkeypatch: MonkeyPatch ) -> None: @@ -54,7 +49,7 @@ def test_controller_type_is_none_after_init( ) # Controller detection is now deferred to run_tests() - assert orchestrator.controller_type is None + assert orchestrator.controller_context is None def test_controller_detected_during_run_tests( self, tmp_path: Path, monkeypatch: MonkeyPatch @@ -78,7 +73,7 @@ def test_controller_detected_during_run_tests( dev_pyats_only=True, ) - assert orchestrator.controller_type is None + assert orchestrator.controller_context is None with ( patch.object( @@ -104,7 +99,12 @@ def test_controller_detected_during_run_tests( orchestrator.run_tests() # Controller should now be detected - assert orchestrator.controller_type == "ACI" + assert orchestrator.controller_context is not None + # Note: mypy flags this as unreachable because it loses type narrowing after + # the method call above. The assertion at line 107 narrows the type, but mypy + # conservatively assumes run_tests() could have mutated controller_context back + # to None. This is a known mypy limitation with attribute narrowing across calls. + assert orchestrator.controller_context.controller_type == "ACI" # type: ignore[unreachable] def test_detection_failure_continues_with_preflight_failure( self, tmp_path: Path @@ -150,7 +150,7 @@ def test_detection_failure_continues_with_preflight_failure( assert results.pre_flight_failure.controller_url is None def test_combined_orchestrator_passes_controller_to_pyats( - self, tmp_path: Path, monkeypatch: MonkeyPatch + self, tmp_path: Path, monkeypatch: MonkeyPatch, sdwan_context: ControllerContext ) -> None: """Test that CombinedOrchestrator passes controller type to PyATSOrchestrator.""" # Set up SDWAN credentials @@ -226,14 +226,14 @@ def test_combined_orchestrator_passes_controller_to_pyats( # Run tests orchestrator.run_tests() - # Verify PyATSOrchestrator was called with controller_type + # Verify PyATSOrchestrator was called with controller_context mock_pyats.assert_called_once_with( data_paths=[data_dir], test_dir=templates_dir, output_dir=output_dir, minimal_reports=False, custom_testbed_path=None, - controller_type="SDWAN", + controller_context=sdwan_context, dry_run=False, verbose=False, loglevel=DEFAULT_LOGLEVEL, @@ -292,7 +292,7 @@ def test_render_only_mode_does_not_instantiate_pyats_orchestrator( ) # Verify controller_type is empty (no detection occurred) - assert orchestrator.controller_type is None + assert orchestrator.controller_context is None # Mock PyATSOrchestrator to verify it's never instantiated with patch("nac_test.combined_orchestrator.PyATSOrchestrator") as mock_pyats: @@ -305,23 +305,23 @@ def test_render_only_mode_does_not_instantiate_pyats_orchestrator( with ( patch( - "nac_test.combined_orchestrator.detect_controller_type" - ) as mock_detect, + "nac_test.combined_orchestrator.resolve_controller" + ) as mock_resolve, patch("typer.echo"), patch("typer.secho"), ): # Run tests orchestrator.run_tests() - # detect_controller_type should NOT be called in render-only mode - mock_detect.assert_not_called() + # resolve_controller should NOT be called in render-only mode + mock_resolve.assert_not_called() # CRITICAL ASSERTION: PyATSOrchestrator must NEVER be instantiated mock_pyats.assert_not_called() # Robot must be called mock_robot.assert_called_once() def test_combined_orchestrator_production_mode_passes_controller( - self, tmp_path: Path, monkeypatch: MonkeyPatch + self, tmp_path: Path, monkeypatch: MonkeyPatch, cc_context: ControllerContext ) -> None: """Test that CombinedOrchestrator passes controller type in production mode.""" # Set up CC credentials @@ -361,7 +361,7 @@ def test_combined_orchestrator_production_mode_passes_controller( ) # Controller type should be None after init (deferred to run_tests) - assert orchestrator.controller_type is None + assert orchestrator.controller_context is None # Mock PyATSOrchestrator and discovery with patch("nac_test.combined_orchestrator.PyATSOrchestrator") as mock_pyats: @@ -390,7 +390,7 @@ def test_combined_orchestrator_production_mode_passes_controller( # Run tests orchestrator.run_tests() - # Verify PyATSOrchestrator was called with controller_type + # Verify PyATSOrchestrator was called with controller_context mock_pyats.assert_called_once_with( data_paths=[data_dir], @@ -398,7 +398,7 @@ def test_combined_orchestrator_production_mode_passes_controller( output_dir=output_dir, minimal_reports=False, custom_testbed_path=None, - controller_type="CC", + controller_context=cc_context, dry_run=False, verbose=False, loglevel=DEFAULT_LOGLEVEL, diff --git a/tests/unit/test_combined_orchestrator_flow.py b/tests/unit/test_combined_orchestrator_flow.py index 19d886cd..bfd8d77e 100644 --- a/tests/unit/test_combined_orchestrator_flow.py +++ b/tests/unit/test_combined_orchestrator_flow.py @@ -20,7 +20,12 @@ from _pytest.monkeypatch import MonkeyPatch from nac_test.combined_orchestrator import CombinedOrchestrator -from nac_test.core.types import CombinedResults, PyATSResults, TestResults +from nac_test.core.types import ( + CombinedResults, + ControllerContext, + PyATSResults, + TestResults, +) from tests.unit.conftest import AUTH_SUCCESS @@ -43,16 +48,18 @@ def setup_controller_env(self, monkeypatch: MonkeyPatch) -> None: monkeypatch.setenv("ACI_PASSWORD", "password") @pytest.fixture(autouse=True) - def _mock_preflight_auth(self) -> Generator[None, None, None]: - """Mock controller detection and preflight auth for all tests. + def _mock_preflight_auth( + self, aci_context: ControllerContext + ) -> Generator[None, None, None]: + """Mock controller resolution and preflight auth for all tests. These are only reached when has_pyats=True and not render_only, but having them present for all tests is harmless. """ with ( patch( - "nac_test.combined_orchestrator.detect_controller_type", - return_value="ACI", + "nac_test.combined_orchestrator.resolve_controller", + return_value=aci_context, ), patch( "nac_test.combined_orchestrator.preflight_auth_check", diff --git a/tests/unit/test_combined_orchestrator_python311.py b/tests/unit/test_combined_orchestrator_python311.py index 9536b73b..3e7f0763 100644 --- a/tests/unit/test_combined_orchestrator_python311.py +++ b/tests/unit/test_combined_orchestrator_python311.py @@ -11,16 +11,13 @@ from _pytest.monkeypatch import MonkeyPatch from nac_test.combined_orchestrator import CombinedOrchestrator +from nac_test.core.types import ControllerContext from tests.unit.conftest import AUTH_SUCCESS class TestOrchestratorUnsupportedPythonExit: """Tests for the orchestrator-level macOS unsupported Python hard exit.""" - @pytest.fixture(autouse=True) - def _clean_env(self, clean_controller_env: None) -> None: - """Apply shared clean_controller_env fixture to all tests in this class.""" - def _make_orchestrator( self, tmp_path: Path, monkeypatch: MonkeyPatch, *, dev_pyats_only: bool = True ) -> CombinedOrchestrator: @@ -60,7 +57,7 @@ def test_check_python_version_passes_on_supported_platform(self) -> None: CombinedOrchestrator._check_python_version() def test_pyats_only_mode_triggers_check( - self, tmp_path: Path, monkeypatch: MonkeyPatch + self, tmp_path: Path, monkeypatch: MonkeyPatch, aci_context: ControllerContext ) -> None: """Dev pyats-only mode must call _check_python_version and exit on unsupported macOS Python.""" orchestrator = self._make_orchestrator( @@ -75,8 +72,8 @@ def test_pyats_only_mode_triggers_check( orchestrator, "_discover_test_types", return_value=(True, False) ), patch( - "nac_test.combined_orchestrator.detect_controller_type", - return_value="ACI", + "nac_test.combined_orchestrator.resolve_controller", + return_value=aci_context, ), patch( "nac_test.combined_orchestrator.preflight_auth_check", diff --git a/tests/unit/test_env.py b/tests/unit/test_env.py index 380797e9..f7ae5f9a 100644 --- a/tests/unit/test_env.py +++ b/tests/unit/test_env.py @@ -7,7 +7,7 @@ import pytest -from nac_test._env import get_bool_env, get_positive_numeric_env +from nac_test._env import get_bool_env, get_positive_numeric_env, is_env_var_set class TestGetPositiveNumericEnv: @@ -112,3 +112,25 @@ def test_returns_expected( def test_returns_default_when_not_set(self) -> None: assert get_bool_env("NAC_TEST_UNSET_BOOL_VAR") is False assert get_bool_env("NAC_TEST_UNSET_BOOL_VAR", default=True) is True + + +class TestIsEnvVarSet: + """Tests for is_env_var_set().""" + + @pytest.mark.parametrize( + ("env_value", "expected"), + [ + ("value", True), # non-empty value + (" value ", True), # whitespace-padded content + ("", False), # empty string + (" ", False), # whitespace only + ], + ) + def test_returns_expected( + self, monkeypatch: pytest.MonkeyPatch, env_value: str, expected: bool + ) -> None: + monkeypatch.setenv("NAC_TEST_VAR", env_value) + assert is_env_var_set("NAC_TEST_VAR") is expected + + def test_returns_false_for_unset_var(self) -> None: + assert is_env_var_set("NAC_TEST_DEFINITELY_NOT_SET_VAR") is False diff --git a/tests/unit/test_terminal.py b/tests/unit/test_terminal.py index 6f761852..3c266170 100644 --- a/tests/unit/test_terminal.py +++ b/tests/unit/test_terminal.py @@ -211,63 +211,3 @@ def test_no_color_env_disables_all_colors( finally: # Restore original value TerminalColors.NO_COLOR = original_no_color - - -class TestTerminalErrorMessages: - """Test the updated error messages for controller auto-detection.""" - - def test_format_env_var_error_auto_detection_messaging(self) -> None: - """Verify error message explains auto-detection and does not mention CONTROLLER_TYPE.""" - missing_vars = ["ACI_URL", "ACI_PASSWORD"] - controller_type = "ACI" - - error_msg = terminal.format_env_var_error(missing_vars, controller_type) - plain_msg = terminal.strip_ansi(error_msg) - - assert "automatically detects" in plain_msg - assert "Controller type detected: ACI" in plain_msg - assert "To switch to a different controller:" in plain_msg - assert "unset ACI_URL ACI_USERNAME ACI_PASSWORD" in plain_msg - - # Ensure CONTROLLER_TYPE is NOT mentioned - assert "CONTROLLER_TYPE" not in plain_msg - assert "export CONTROLLER_TYPE" not in plain_msg - - def test_format_env_var_error_includes_all_controllers(self) -> None: - """Verify error message includes examples for all supported controllers.""" - missing_vars = ["SDWAN_USERNAME"] - controller_type = "SDWAN" - - error_msg = terminal.format_env_var_error(missing_vars, controller_type) - plain_msg = terminal.strip_ansi(error_msg) - - controllers = ["ACI", "SDWAN", "CC", "MERAKI", "FMC", "ISE"] - for controller in controllers: - assert f"{controller}_URL" in plain_msg - assert f"{controller}_USERNAME" in plain_msg - assert f"{controller}_PASSWORD" in plain_msg - - def test_format_env_var_error_shows_missing_vars(self) -> None: - """Verify error message lists all missing variables.""" - missing_vars = ["CC_URL", "CC_USERNAME", "CC_PASSWORD"] - controller_type = "CC" - - error_msg = terminal.format_env_var_error(missing_vars, controller_type) - plain_msg = terminal.strip_ansi(error_msg) - - for var in missing_vars: - assert var in plain_msg - - def test_format_env_var_error_actionable_instructions(self) -> None: - """Verify error message provides clear actionable instructions.""" - missing_vars = ["MERAKI_PASSWORD"] - controller_type = "MERAKI" - - error_msg = terminal.format_env_var_error(missing_vars, controller_type) - plain_msg = terminal.strip_ansi(error_msg) - - assert "unset MERAKI_URL MERAKI_USERNAME MERAKI_PASSWORD" in plain_msg - assert "Then set credentials for your desired controller:" in plain_msg - assert "export MERAKI_URL" in plain_msg - assert "export MERAKI_USERNAME" in plain_msg - assert "export MERAKI_PASSWORD" in plain_msg diff --git a/tests/utils/test_cleanup.py b/tests/utils/test_cleanup.py index 7786d77b..9c3b1e2f 100644 --- a/tests/utils/test_cleanup.py +++ b/tests/utils/test_cleanup.py @@ -11,7 +11,7 @@ from pytest_mock import MockerFixture from nac_test.core.constants import PYATS_RESULTS_DIRNAME -from nac_test.pyats_core.discovery.test_type_resolver import VALID_TEST_TYPES +from nac_test.pyats_core.constants import VALID_TEST_TYPES from nac_test.utils.cleanup import cleanup_stale_test_artifacts diff --git a/tests/utils/test_controller.py b/tests/utils/test_controller.py deleted file mode 100644 index 670991dc..00000000 --- a/tests/utils/test_controller.py +++ /dev/null @@ -1,725 +0,0 @@ -# SPDX-License-Identifier: MPL-2.0 -# Copyright (c) 2025 Daniel Schmidt - -"""Tests for controller type detection utilities.""" - -import os -from collections.abc import Generator -from unittest.mock import patch - -import pytest - -from nac_test.utils.controller import ( - CONTROLLER_REGISTRY, - CredentialSet, - _find_credential_sets, - _format_multiple_credentials_error, - _format_no_credentials_error, - _matched_credential_sets, - detect_controller_type, - get_controller_url, - get_matched_credential_set, -) -from nac_test.utils.environment import EnvironmentValidator - - -@pytest.fixture(autouse=True) -def clean_environment() -> Generator[None, None, None]: - """Clean controller env vars and matched-credential cache for every test.""" - original_env = os.environ.copy() - - for config in CONTROLLER_REGISTRY.values(): - for cred_set in config.credential_sets: - for var in cred_set.env_vars: - os.environ.pop(var, None) - - os.environ.pop("CONTROLLER_TYPE", None) - _matched_credential_sets.clear() - - yield - - _matched_credential_sets.clear() - os.environ.clear() - os.environ.update(original_env) - - -class TestControllerDetection: - """Test controller type detection functionality.""" - - @pytest.mark.parametrize( - "controller_type,env_vars", - [ - ("ACI", ["ACI_URL", "ACI_USERNAME", "ACI_PASSWORD"]), - ("SDWAN", ["SDWAN_URL", "SDWAN_USERNAME", "SDWAN_PASSWORD"]), - ("CC", ["CC_URL", "CC_USERNAME", "CC_PASSWORD"]), - ("MERAKI", ["MERAKI_URL", "MERAKI_USERNAME", "MERAKI_PASSWORD"]), - ("FMC", ["FMC_URL", "FMC_USERNAME", "FMC_PASSWORD"]), - ("ISE", ["ISE_URL", "ISE_USERNAME", "ISE_PASSWORD"]), - ], - ) - def test_detect_single_controller_type( - self, controller_type: str, env_vars: list[str] - ) -> None: - """Test detection of each supported controller type.""" - # Set complete credentials for one controller - os.environ[env_vars[0]] = f"https://{controller_type.lower()}.example.com" - os.environ[env_vars[1]] = "testuser" - os.environ[env_vars[2]] = "testpass" - - result = detect_controller_type() - assert result == controller_type - - def test_multiple_controllers_error(self) -> None: - """Test error when multiple controllers have complete credentials.""" - # Set credentials for ACI - os.environ["ACI_URL"] = "https://apic.example.com" - os.environ["ACI_USERNAME"] = "aci_user" - os.environ["ACI_PASSWORD"] = "aci_pass" - - # Set credentials for SDWAN - os.environ["SDWAN_URL"] = "https://vmanage.example.com" - os.environ["SDWAN_USERNAME"] = "sdwan_user" - os.environ["SDWAN_PASSWORD"] = "sdwan_pass" - - with pytest.raises(ValueError) as exc_info: - detect_controller_type() - - error_msg = str(exc_info.value) - assert "Multiple controller credentials detected: ACI, SDWAN" in error_msg - # SDWAN has multiple credential sets — unset should include all env vars - assert ( - "unset SDWAN_URL SDWAN_API_TOKEN SDWAN_USERNAME SDWAN_PASSWORD" in error_msg - ) - assert "unset ACI_URL ACI_USERNAME ACI_PASSWORD" in error_msg - - def test_no_credentials_error(self) -> None: - """Test error when no controller credentials are found.""" - # Ensure environment is completely clean - for config in CONTROLLER_REGISTRY.values(): - for cred_set in config.credential_sets: - for var in cred_set.env_vars: - os.environ.pop(var, None) - - with pytest.raises(ValueError) as exc_info: - detect_controller_type() - - error_msg = str(exc_info.value) - assert "No controller credentials found in environment" in error_msg - assert "Controller credentials are required for ALL test types" in error_msg - assert "export ACI_URL=" in error_msg - assert "export SDWAN_URL=" in error_msg - - def test_partial_credentials_error(self) -> None: - """Test error when controller has incomplete credentials.""" - # Set only URL and username for ACI (missing password) - os.environ["ACI_URL"] = "https://apic.example.com" - os.environ["ACI_USERNAME"] = "admin" - # Deliberately not setting ACI_PASSWORD - - with pytest.raises(ValueError) as exc_info: - detect_controller_type() - - error_msg = str(exc_info.value) - assert "Incomplete controller credentials detected" in error_msg - assert "ACI: incomplete credentials" in error_msg - - def test_empty_string_handling(self) -> None: - """Test that empty string values are treated as missing.""" - # Set ACI credentials with empty password - os.environ["ACI_URL"] = "https://apic.example.com" - os.environ["ACI_USERNAME"] = "admin" - os.environ["ACI_PASSWORD"] = "" # Empty string - - with pytest.raises(ValueError) as exc_info: - detect_controller_type() - - error_msg = str(exc_info.value) - assert "Incomplete controller credentials detected" in error_msg - assert "ACI: incomplete credentials" in error_msg - - def test_whitespace_handling(self) -> None: - """Test that whitespace-only values are treated as missing.""" - # Set SDWAN credentials with whitespace-only password - os.environ["SDWAN_URL"] = "https://vmanage.example.com" - os.environ["SDWAN_USERNAME"] = "admin" - os.environ["SDWAN_PASSWORD"] = " " # Only whitespace - - with pytest.raises(ValueError) as exc_info: - detect_controller_type() - - error_msg = str(exc_info.value) - assert "Incomplete controller credentials detected" in error_msg - assert "SDWAN: incomplete credentials" in error_msg - - def test_d2d_scenario_with_dummy_credentials(self) -> None: - """Test D2D scenario where controller credentials are still required.""" - # Set complete ACI credentials (even for D2D tests) - os.environ["ACI_URL"] = "https://dummy.controller.local" - os.environ["ACI_USERNAME"] = "dummy" - os.environ["ACI_PASSWORD"] = "dummy" - - # Also set device credentials (for D2D) - os.environ["IOSXE_USERNAME"] = "device_user" - os.environ["IOSXE_PASSWORD"] = "device_pass" - - result = detect_controller_type() - assert result == "ACI" # Controller type still detected - - -class TestHelperFunctions: - """Test helper functions for credential detection.""" - - def test_find_credential_sets_complete(self) -> None: - """Test finding complete credential sets.""" - # Set complete credentials for CC - os.environ["CC_URL"] = "https://cc.example.com" - os.environ["CC_USERNAME"] = "admin" - os.environ["CC_PASSWORD"] = "password" - - complete, partial = _find_credential_sets() - - assert list(complete.keys()) == ["CC"] - assert partial == [] - assert "CC" in complete - assert complete["CC"].auth_method == "session" - - def test_find_credential_sets_partial(self) -> None: - """Test finding partial credential sets.""" - # Set partial credentials for FMC (missing password) - os.environ["FMC_URL"] = "https://fmc.example.com" - os.environ["FMC_USERNAME"] = "admin" - # No FMC_PASSWORD - - complete, partial = _find_credential_sets() - - assert complete == {} - assert "FMC" in partial - - def test_find_credential_sets_multiple_partial(self) -> None: - """Test finding multiple partial credential sets.""" - # Partial ISE credentials - os.environ["ISE_URL"] = "https://ise.example.com" - # Missing ISE_USERNAME and ISE_PASSWORD - - # Partial MERAKI credentials - os.environ["MERAKI_USERNAME"] = "meraki_user" - # Missing MERAKI_URL and MERAKI_PASSWORD - - complete, partial = _find_credential_sets() - - assert complete == {} - assert len(partial) == 2 - assert "ISE" in partial - assert "MERAKI" in partial - - def test_format_multiple_credentials_error(self) -> None: - """Test formatting error message for multiple controllers.""" - error_msg = _format_multiple_credentials_error(["ACI", "SDWAN", "CC"]) - - assert "Multiple controller credentials detected: ACI, SDWAN, CC" in error_msg - assert "To use ACI only:" in error_msg - # SDWAN has two credential sets, so all env vars from both sets appear - assert ( - "unset SDWAN_URL SDWAN_API_TOKEN SDWAN_USERNAME SDWAN_PASSWORD" in error_msg - ) - assert "CC_URL CC_USERNAME CC_PASSWORD" in error_msg - assert "To use SDWAN only:" in error_msg - assert ( - "unset ACI_URL ACI_USERNAME ACI_PASSWORD CC_URL CC_USERNAME CC_PASSWORD" - in error_msg - ) - assert "To use CC only:" in error_msg - assert "Use a separate shell session" in error_msg - - def test_format_no_credentials_error(self) -> None: - """Test formatting error message for no credentials.""" - error_msg = _format_no_credentials_error() - - assert "No controller credentials found in environment" in error_msg - assert "Controller credentials are required for ALL test types" in error_msg - assert "ACI:" in error_msg - assert "export ACI_URL=" in error_msg - assert "SDWAN:" in error_msg - assert "export SDWAN_URL=" in error_msg - assert "Example for ACI:" in error_msg - assert "Set credentials for only ONE controller type at a time" in error_msg - - -class TestEdgeCases: - """Test edge cases and special scenarios.""" - - def test_case_sensitivity(self) -> None: - """Test that environment variable names are case-sensitive.""" - # Set lowercase variables (should not be detected) - os.environ["aci_url"] = "https://apic.example.com" - os.environ["aci_username"] = "admin" - os.environ["aci_password"] = "password" - - with pytest.raises(ValueError) as exc_info: - detect_controller_type() - - assert "No controller credentials found" in str(exc_info.value) - - def test_special_characters_in_credentials(self) -> None: - """Test handling of special characters in credential values.""" - # Set credentials with special characters - os.environ["CC_URL"] = "https://cc.example.com:8443/path" - os.environ["CC_USERNAME"] = "user@domain.com" - os.environ["CC_PASSWORD"] = "p@$$w0rd!#$%^&*()" - - result = detect_controller_type() - assert result == "CC" - - def test_legacy_controller_type_ignored(self) -> None: - """Test that legacy CONTROLLER_TYPE variable is ignored.""" - # Set legacy CONTROLLER_TYPE (should be ignored) - os.environ["CONTROLLER_TYPE"] = "APIC" - - # Set actual SDWAN credentials - os.environ["SDWAN_URL"] = "https://vmanage.example.com" - os.environ["SDWAN_USERNAME"] = "admin" - os.environ["SDWAN_PASSWORD"] = "password" - - result = detect_controller_type() - assert ( - result == "SDWAN" - ) # Should use credential-based detection, not CONTROLLER_TYPE - - def test_mixed_complete_and_partial_credentials(self) -> None: - """Test scenario with one complete and one partial credential set.""" - # Complete FMC credentials - os.environ["FMC_URL"] = "https://fmc.example.com" - os.environ["FMC_USERNAME"] = "admin" - os.environ["FMC_PASSWORD"] = "password" - - # Partial ISE credentials (missing password) - os.environ["ISE_URL"] = "https://ise.example.com" - os.environ["ISE_USERNAME"] = "ise_admin" - - result = detect_controller_type() - assert result == "FMC" # Should detect the complete set - - def test_whitespace_trimming_in_values(self) -> None: - """Test that leading/trailing whitespace in values is handled correctly.""" - # Set credentials with extra whitespace (should still work) - os.environ["MERAKI_URL"] = " https://meraki.example.com " - os.environ["MERAKI_USERNAME"] = " admin " - os.environ["MERAKI_PASSWORD"] = " password " - - result = detect_controller_type() - assert result == "MERAKI" - - @patch.dict(os.environ, {}, clear=True) - def test_truly_empty_environment(self) -> None: - """Test with a completely empty environment.""" - with pytest.raises(ValueError) as exc_info: - detect_controller_type() - - error_msg = str(exc_info.value) - assert "No controller credentials found" in error_msg - - def test_three_way_multiple_controllers(self) -> None: - """Test error message with three controllers configured.""" - # Set credentials for three controllers - os.environ["ACI_URL"] = "https://apic.example.com" - os.environ["ACI_USERNAME"] = "aci_user" - os.environ["ACI_PASSWORD"] = "aci_pass" - - os.environ["CC_URL"] = "https://cc.example.com" - os.environ["CC_USERNAME"] = "cc_user" - os.environ["CC_PASSWORD"] = "cc_pass" - - os.environ["ISE_URL"] = "https://ise.example.com" - os.environ["ISE_USERNAME"] = "ise_user" - os.environ["ISE_PASSWORD"] = "ise_pass" - - with pytest.raises(ValueError) as exc_info: - detect_controller_type() - - error_msg = str(exc_info.value) - assert "Multiple controller credentials detected: ACI, CC, ISE" in error_msg - assert "To use ACI only:" in error_msg - assert "To use CC only:" in error_msg - assert "To use ISE only:" in error_msg - - def test_unicode_in_credentials(self) -> None: - """Test handling of unicode characters in credentials.""" - # Set credentials with unicode characters - os.environ["ACI_URL"] = "https://apic.example.com" - os.environ["ACI_USERNAME"] = "用户名" # Chinese characters - os.environ["ACI_PASSWORD"] = "пароль" # Cyrillic characters - - result = detect_controller_type() - assert result == "ACI" - - def test_url_with_path_and_query(self) -> None: - """Test URL values with paths and query parameters.""" - os.environ["SDWAN_URL"] = "https://vmanage.example.com:8443/api/v1?test=true" - os.environ["SDWAN_USERNAME"] = "admin" - os.environ["SDWAN_PASSWORD"] = "password" - - result = detect_controller_type() - assert result == "SDWAN" - - def test_iosxe_partial_and_sdwan_partial_are_both_reported(self) -> None: - """Regression test: IOSXE_URL + IOSXE_PASSWORD (no IOSXE_USERNAME) combined - with SDWAN_URL must NOT detect IOSXE — both controllers should be reported - as partial, not complete. - - Before the fix that added IOSXE_USERNAME/IOSXE_PASSWORD to the IOSXE - credential sets, setting only IOSXE_URL was enough to satisfy detection, - so this combination incorrectly returned 'IOSXE' instead of raising. - """ - os.environ["IOSXE_URL"] = "https://iosxe.example.com" - os.environ["IOSXE_PASSWORD"] = "cisco123" - # IOSXE_USERNAME deliberately omitted — credential set must not be satisfied - os.environ["SDWAN_URL"] = "https://vmanage.example.com" - # No SDWAN credentials beyond URL - - with pytest.raises(ValueError) as exc_info: - detect_controller_type() - - error_msg = str(exc_info.value) - assert "Incomplete controller credentials detected" in error_msg - assert "IOSXE: incomplete credentials" in error_msg - assert "SDWAN: incomplete credentials" in error_msg - - -class TestIOSXEAlternativeURLEnvVar: - """Test IOSXE controller detection with alternative URL environment variables. - - IOSXE supports both IOSXE_URL and IOSXE_HOST as the URL environment variable - via separate credential sets. The first matching credential set wins. - """ - - def test_detect_iosxe_with_url(self) -> None: - """Test IOSXE detection with standard IOSXE_URL env var.""" - os.environ["IOSXE_URL"] = "https://iosxe.example.com" - os.environ["IOSXE_USERNAME"] = "admin" - os.environ["IOSXE_PASSWORD"] = "password" - - result = detect_controller_type() - assert result == "IOSXE" - - def test_detect_iosxe_with_host(self) -> None: - """Test IOSXE detection with alternative IOSXE_HOST env var.""" - os.environ["IOSXE_HOST"] = "192.168.1.1" - os.environ["IOSXE_USERNAME"] = "admin" - os.environ["IOSXE_PASSWORD"] = "password" - - result = detect_controller_type() - assert result == "IOSXE" - - def test_iosxe_url_takes_precedence_over_host(self) -> None: - """When both IOSXE_URL and IOSXE_HOST are set, URL takes precedence.""" - os.environ["IOSXE_URL"] = "https://iosxe-url.example.com" - os.environ["IOSXE_HOST"] = "192.168.1.1" - os.environ["IOSXE_USERNAME"] = "admin" - os.environ["IOSXE_PASSWORD"] = "password" - - result = detect_controller_type() - assert result == "IOSXE" - - # Verify URL takes precedence in get_controller_url - url = get_controller_url("IOSXE") - assert url == "https://iosxe-url.example.com" - - def test_get_controller_url_returns_iosxe_url(self) -> None: - """get_controller_url returns IOSXE_URL when set.""" - os.environ["IOSXE_URL"] = "https://iosxe.example.com" - - url = get_controller_url("IOSXE") - assert url == "https://iosxe.example.com" - - def test_get_controller_url_returns_iosxe_host(self) -> None: - """get_controller_url returns IOSXE_HOST when IOSXE_URL is not set.""" - os.environ["IOSXE_HOST"] = "192.168.1.1" - - url = get_controller_url("IOSXE") - assert url == "192.168.1.1" - - def test_get_controller_url_raises_when_neither_set(self) -> None: - """get_controller_url raises KeyError when neither URL nor HOST is set.""" - # Neither IOSXE_URL nor IOSXE_HOST is set - - with pytest.raises(KeyError) as exc_info: - get_controller_url("IOSXE") - - assert "IOSXE_URL" in str(exc_info.value) - - def test_get_controller_url_strips_whitespace(self) -> None: - """get_controller_url strips leading/trailing whitespace.""" - os.environ["IOSXE_HOST"] = " 192.168.1.1 " - - url = get_controller_url("IOSXE") - assert url == "192.168.1.1" - - def test_get_controller_url_empty_url_falls_back_to_host(self) -> None: - """get_controller_url uses IOSXE_HOST when IOSXE_URL is empty.""" - os.environ["IOSXE_URL"] = "" - os.environ["IOSXE_HOST"] = "192.168.1.1" - - url = get_controller_url("IOSXE") - assert url == "192.168.1.1" - - def test_get_controller_url_whitespace_url_falls_back_to_host(self) -> None: - """get_controller_url uses IOSXE_HOST when IOSXE_URL is only whitespace.""" - os.environ["IOSXE_URL"] = " " - os.environ["IOSXE_HOST"] = "192.168.1.1" - - url = get_controller_url("IOSXE") - assert url == "192.168.1.1" - - -class TestGetControllerUrl: - """Tests for get_controller_url function.""" - - @pytest.mark.parametrize( - "controller_type,url_env_var", - [ - ("ACI", "ACI_URL"), - ("SDWAN", "SDWAN_URL"), - ("CC", "CC_URL"), - ("MERAKI", "MERAKI_URL"), - ("FMC", "FMC_URL"), - ("ISE", "ISE_URL"), - ("IOSXE", "IOSXE_URL"), - ], - ) - def test_get_controller_url_returns_correct_value( - self, controller_type: str, url_env_var: str - ) -> None: - """Test that get_controller_url returns the correct URL for each controller.""" - expected_url = f"https://{controller_type.lower()}.example.com" - os.environ[url_env_var] = expected_url - - result = get_controller_url(controller_type) - assert result == expected_url - - def test_get_controller_url_unknown_controller_fallback(self) -> None: - """Test fallback for unknown controller types.""" - os.environ["UNKNOWN_URL"] = "https://unknown.example.com" - - result = get_controller_url("UNKNOWN") - assert result == "https://unknown.example.com" - - def test_get_controller_url_unknown_controller_raises_when_not_set(self) -> None: - """Test that unknown controller type raises KeyError when env var not set.""" - with pytest.raises(KeyError) as exc_info: - get_controller_url("NONEXISTENT") - - assert "NONEXISTENT_URL" in str(exc_info.value) - - -class TestSDWANCredentialSets: - """Test SDWAN controller detection with multiple credential sets. - - SDWAN supports two credential methods: - 1. API Token (20.18+): SDWAN_URL + SDWAN_API_TOKEN (first — wins when both present) - 2. Username/Password: SDWAN_URL + SDWAN_USERNAME + SDWAN_PASSWORD - """ - - def test_detect_sdwan_with_api_token(self) -> None: - """Test SDWAN detection with API token credentials.""" - os.environ["SDWAN_URL"] = "https://vmanage.example.com" - os.environ["SDWAN_API_TOKEN"] = "eyJhbGciOiJSUzI1NiJ9.test.sig" - - result = detect_controller_type() - assert result == "SDWAN" - - # Token set should be matched with auth_method="token" - cred = get_matched_credential_set("SDWAN") - assert cred is not None - assert cred.auth_method == "token" - assert cred.label == "API Token (20.18+)" - - def test_detect_sdwan_with_username_password(self) -> None: - """Test SDWAN detection with traditional username/password.""" - os.environ["SDWAN_URL"] = "https://vmanage.example.com" - os.environ["SDWAN_USERNAME"] = "admin" - os.environ["SDWAN_PASSWORD"] = "password" - - result = detect_controller_type() - assert result == "SDWAN" - - # Password set should be matched with auth_method="session" - cred = get_matched_credential_set("SDWAN") - assert cred is not None - assert cred.auth_method == "session" - assert cred.label == "Username/Password" - - def test_api_token_takes_priority(self) -> None: - """When both credential sets are satisfied, token set wins (listed first).""" - os.environ["SDWAN_URL"] = "https://vmanage.example.com" - os.environ["SDWAN_API_TOKEN"] = "eyJhbGciOiJSUzI1NiJ9.test.sig" - os.environ["SDWAN_USERNAME"] = "admin" - os.environ["SDWAN_PASSWORD"] = "password" - - # Should still detect exactly one SDWAN (not duplicate) - result = detect_controller_type() - assert result == "SDWAN" - - # Token set wins because it's listed first - cred = get_matched_credential_set("SDWAN") - assert cred is not None - assert cred.auth_method == "token" - - def test_partial_token_set_falls_back_to_password(self) -> None: - """When SDWAN_API_TOKEN is missing but username/password present, detect SDWAN.""" - os.environ["SDWAN_URL"] = "https://vmanage.example.com" - # No SDWAN_API_TOKEN - os.environ["SDWAN_USERNAME"] = "admin" - os.environ["SDWAN_PASSWORD"] = "password" - - result = detect_controller_type() - assert result == "SDWAN" - - # Password set matched because token set was incomplete - cred = get_matched_credential_set("SDWAN") - assert cred is not None - assert cred.auth_method == "session" - - def test_empty_api_token_falls_back_to_password(self) -> None: - """Empty SDWAN_API_TOKEN should not satisfy the token credential set.""" - os.environ["SDWAN_URL"] = "https://vmanage.example.com" - os.environ["SDWAN_API_TOKEN"] = "" - os.environ["SDWAN_USERNAME"] = "admin" - os.environ["SDWAN_PASSWORD"] = "password" - - result = detect_controller_type() - assert result == "SDWAN" - - # Should fall back to session auth - cred = get_matched_credential_set("SDWAN") - assert cred is not None - assert cred.auth_method == "session" - - def test_url_only_is_partial(self) -> None: - """SDWAN_URL alone (no token, no username/password) is partial.""" - os.environ["SDWAN_URL"] = "https://vmanage.example.com" - - with pytest.raises(ValueError) as exc_info: - detect_controller_type() - - error_msg = str(exc_info.value) - assert "Incomplete controller credentials detected" in error_msg - assert "SDWAN: incomplete credentials" in error_msg - assert "API Token (20.18+)" in error_msg - assert "Username/Password" in error_msg - - def test_incomplete_error_shows_credential_set_options(self) -> None: - """Incomplete SDWAN credentials should list both credential set options.""" - os.environ["SDWAN_URL"] = "https://vmanage.example.com" - - with pytest.raises(ValueError) as exc_info: - detect_controller_type() - - error_msg = str(exc_info.value) - assert "API Token (20.18+)" in error_msg - assert "Username/Password" in error_msg - - def test_get_matched_credential_set_before_detection(self) -> None: - """get_matched_credential_set returns None before detect_controller_type runs.""" - assert get_matched_credential_set("SDWAN") is None - - def test_credential_set_auth_method_default(self) -> None: - """CredentialSet.auth_method defaults to 'session'.""" - cs = CredentialSet(env_vars=("X_URL", "X_USER", "X_PASS"), label="test") - assert cs.auth_method == "session" - - def test_aci_matched_credential_set(self) -> None: - """ACI detection stores matched credential set with session auth.""" - os.environ["ACI_URL"] = "https://apic.example.com" - os.environ["ACI_USERNAME"] = "admin" - os.environ["ACI_PASSWORD"] = "password" - - detect_controller_type() - - cred = get_matched_credential_set("ACI") - assert cred is not None - assert cred.auth_method == "session" - assert cred.label == "Username/Password" - - -class TestGetControllerUrlSDWAN: - """Tests for get_controller_url with multi-credential-set controllers (SDWAN).""" - - def test_sdwan_url_returned_when_set(self) -> None: - """get_controller_url returns SDWAN_URL directly from url_env_var.""" - os.environ["SDWAN_URL"] = "https://vmanage.example.com" - os.environ["SDWAN_API_TOKEN"] = "some-token" - - url = get_controller_url("SDWAN") - assert url == "https://vmanage.example.com" - - def test_sdwan_does_not_return_token_when_url_empty(self) -> None: - """get_controller_url raises KeyError when SDWAN_URL is empty, not returning token.""" - os.environ["SDWAN_URL"] = "" - os.environ["SDWAN_API_TOKEN"] = "eyJhbGciOiJSUzI1NiJ9.test.sig" - - with pytest.raises(KeyError) as exc_info: - get_controller_url("SDWAN") - - assert "SDWAN_URL" in str(exc_info.value) - - def test_sdwan_does_not_return_token_when_url_whitespace(self) -> None: - """get_controller_url raises KeyError when SDWAN_URL is whitespace-only.""" - os.environ["SDWAN_URL"] = " " - os.environ["SDWAN_API_TOKEN"] = "some-token" - - with pytest.raises(KeyError) as exc_info: - get_controller_url("SDWAN") - - assert "SDWAN_URL" in str(exc_info.value) - - def test_sdwan_does_not_return_username_when_url_missing(self) -> None: - """get_controller_url raises KeyError, not returning username/password vars.""" - os.environ["SDWAN_USERNAME"] = "admin" - os.environ["SDWAN_PASSWORD"] = "password" - - with pytest.raises(KeyError) as exc_info: - get_controller_url("SDWAN") - - assert "SDWAN_URL" in str(exc_info.value) - - -class TestValidateControllerEnvMultiCredSet: - """Tests for validate_controller_env with multi-credential-set controllers.""" - - def test_sdwan_passes_with_username_password_only(self) -> None: - """validate_controller_env passes when only username/password set (not token).""" - os.environ["SDWAN_URL"] = "https://vmanage.example.com" - os.environ["SDWAN_USERNAME"] = "admin" - os.environ["SDWAN_PASSWORD"] = "password" - - # Should not raise - EnvironmentValidator().validate_controller_env("SDWAN") - - def test_sdwan_passes_with_api_token_only(self) -> None: - """validate_controller_env passes when only API token set.""" - os.environ["SDWAN_URL"] = "https://vmanage.example.com" - os.environ["SDWAN_API_TOKEN"] = "eyJhbGciOiJSUzI1NiJ9.test.sig" - - # Should not raise - EnvironmentValidator().validate_controller_env("SDWAN") - - def test_sdwan_exits_when_no_credential_set_satisfied(self) -> None: - """validate_controller_env exits with all credential sets listed.""" - os.environ["SDWAN_URL"] = "https://vmanage.example.com" - # No token, no username/password - - with pytest.raises(SystemExit) as exc_info: - EnvironmentValidator().validate_controller_env("SDWAN") - - error_msg = str(exc_info.value) - assert "SDWAN: incomplete credentials" in error_msg - assert "API Token (20.18+)" in error_msg - assert "Username/Password" in error_msg - - def test_sdwan_exits_when_nothing_set(self) -> None: - """validate_controller_env exits when no SDWAN vars are set at all.""" - with pytest.raises(SystemExit) as exc_info: - EnvironmentValidator().validate_controller_env("SDWAN") - - error_msg = str(exc_info.value) - assert "SDWAN" in error_msg