Skip to content

feat: implement fail-fast authentication#531

Merged
aitestino merged 102 commits intorelease/pyats-integration-v1.1-betafrom
feature/preflight-controller-auth
Mar 10, 2026
Merged

feat: implement fail-fast authentication#531
aitestino merged 102 commits intorelease/pyats-integration-v1.1-betafrom
feature/preflight-controller-auth

Conversation

@aitestino
Copy link
Copy Markdown
Collaborator

@aitestino aitestino commented Feb 15, 2026

Fixes #491

Summary

This PR implements fail-fast authentication for the nac-test framework (#491) along with comprehensive code quality improvements. When controller authentication fails, the framework now exits immediately with actionable error messages instead of attempting authentication for every test.

Note on Stacked PRs: This PR is built on top of PR #525 (ACI defaults validation). PR #525 will be merged first, after which this PR will be rebased to show only the preflight authentication and refactoring commits. The current diff includes commits from both PRs, but after the rebase, this will show approximately 15 commits focused solely on the authentication improvements.

Related Issues

Key Features

1. Preflight Controller Authentication (Fail Fast)

Problem Solved: Previously, when credentials were incorrect or a controller was unreachable, every test would attempt authentication independently, resulting in N identical failures and wasting execution time.

Solution: Pre-flight authentication check that validates credentials before any test execution begins.

Benefits:

  • ✅ Fails immediately with clear, actionable error messages
  • ✅ Populates AuthCache on success, so first test gets a cache hit
  • ✅ Works for both PyATS and Robot Framework execution modes
  • ✅ Distinguishes between credential failures, network issues, and service unavailability
  • ✅ Generates HTML reports for CI/CD artifact collection

UX Improvements & Manual Testing

See in comments below.

HTML Reports

Generated in output_dir/auth-failure-report.html for CI/CD artifact collection with:

  • Controller type and URL
  • Timestamp of failure
  • Detailed error information
  • Environment variable checklist
  • Controller-specific troubleshooting commands

Benefits

For Users

  • Immediate feedback on credential/connectivity issues (fail fast)
  • Actionable error messages with specific troubleshooting steps
  • Time savings from not running all tests when auth will fail
  • Better CI/CD integration via HTML reports

For Developers

  • Clean architecture with proper separation of concerns
  • Reusable utilities for URL parsing and controller metadata
  • Comprehensive test coverage for all new functionality
  • Type safety improvements for better IDE support

For Framework

  • Extensible design ready for circuit breaker patterns
  • Consistent error handling across all controller types
  • Centralized error classification for future enhancements
  • Maintainable codebase following SRP and DRY principles

Breaking Changes

None. This PR is fully backward compatible.

Merge Strategy

  1. PR Add ACI defaults validation to CLI #525 (ACI defaults validation) will be merged first
  2. netascode/nac-test-pyats-common#27 must be merged and released first — it aligns the APIC env var names (APIC_*ACI_*) with nac-test's CONTROLLER_REGISTRY. CI will not pass until that PR is merged and a new nac-test-pyats-common version is published to PyPI.
  3. This branch will be rebased onto the updated release/pyats-integration-v1.1-beta
  4. The rebased PR will show only the preflight authentication and refactoring commits (~15 commits)
  5. Review can proceed on the cleaner, focused diff
  6. Just like point 2 above, netascode/nac-test-pyats-common#29 must be merged and released first — it fixes SD-WAN Manager auth silently storing the HTML login page as the XSRF token when credentials are invalid (SD-WAN Manager returns HTTP 200 with HTML on auth failure instead of 401/403). CI will not pass until that PR is merged and a new nac-test-pyats-common version is published to PyPI.

Introduces is_architecture_active() helper function that provides a
lightweight check for whether an architecture environment is active by
testing if its URL environment variable is set.

This helper is shared across all architecture-specific validators (ACI,
SD-WAN, Catalyst Center) to avoid duplicating environment detection logic.

Rationale: Validators need to know which architecture is active to decide
whether to perform validation. Centralizing this check in common.py ensures
consistency and makes adding new architecture validators straightforward.
Implements validate_aci_defaults() that fails fast when users forget to
pass the ACI defaults file, catching the common mistake of omitting
-d ./defaults/ before expensive data merge operations.

Two-stage validation approach:

Stage 1 (Instant path-based heuristic):
- Checks if path contains "default" or "defaults" as directory component
- For files: requires .yaml/.yml extension + "default" in filename
- Rejects false positives like "/users/defaultuser/" or "config.txt"

Stage 2 (Content-based fallback):
- Parses YAML files to find "defaults.apic" structure
- Non-recursive directory scanning for performance
- Handles both .yaml and .yml extensions

Security features:
- Rejects symlinks to prevent path traversal
- Limits file scanning to 3MB to prevent memory exhaustion

Performance: Path heuristic provides instant feedback for 95% of cases.
Content scanning only runs when path-based check doesn't match, adding
minimal overhead (~50-100ms) compared to time saved when defaults are
missing (users get immediate feedback instead of waiting for full merge).
Exports validate_aci_defaults() and is_architecture_active() as the
public API for the validators package.

Enforces separation of concerns: validators package only exports
validation functions, not UI components. Display functions like
display_aci_defaults_banner() should be imported directly from
nac_test.cli.ui, maintaining clear architectural boundaries between
validation logic and presentation.

This design allows adding new architecture validators (SD-WAN, Catalyst
Center) by following the same pattern without coupling to UI concerns.
Implements display_aci_defaults_banner() to show a prominent, user-friendly
error message when ACI defaults file is missing.

BoxStyle dataclass design:
- Encapsulates box-drawing characters for terminal rendering
- Frozen dataclass ensures immutability
- Supports both ASCII (fallback) and Unicode (modern terminals)
- Includes emoji_adjustment field to handle Unicode width quirks

NO_COLOR environment variable support:
- Respects NO_COLOR standard for accessibility
- Falls back to plain ASCII when NO_COLOR is set or Unicode unavailable

Banner content includes:
- Clear error message explaining the requirement
- Working example command showing correct syntax
- Link to Cisco ACI documentation for context
- Professional formatting with borders and sections

Rationale: Validation errors need to be immediately visible and actionable.
A prominent banner with example command helps users fix the issue quickly
instead of hunting through error logs.
Exports display_aci_defaults_banner() as the public interface for CLI
error banners. Future banner functions (SD-WAN, Catalyst Center) will
be exported from this package.

Architectural separation: UI package handles all user-facing display
logic, while validators package handles business logic. This separation
allows testing and modifying display behavior independently from
validation rules.
Adds validation check immediately before DataMerger.merge_data_files()
to fail fast when defaults are missing. Validation runs after output
directory creation but before expensive merge operation.

Execution flow:
1. Parse CLI arguments
2. Configure logging
3. Create output directory
4. → NEW: Validate ACI defaults (instant check)
5. Merge data files (expensive operation, 2-5 seconds)
6. Continue with orchestrator...

Error handling:
- Validation failure displays prominent banner via display_aci_defaults_banner()
- Exits with code 1 (failure) to prevent continuing with invalid config
- Blank lines before/after banner improve readability

Time savings: Users get immediate feedback instead of waiting 2-5 seconds
for data merge to complete before seeing validation error. Pre-flight check
philosophy: validate prerequisites before expensive operations.
Creates package structure mirroring source code organization:
- tests/unit/cli/validators/ for validator unit tests
- tests/unit/cli/ui/ for UI component unit tests

Empty __init__.py files make directories importable Python packages,
allowing pytest to discover tests and enabling relative imports within
test modules.

Test organization principle: Test structure should mirror source
structure. This makes finding tests for a given module intuitive and
maintains clear architectural boundaries in both source and test code.
Tests validate_aci_defaults() and helper functions with 32 test cases
covering environment detection, path matching, YAML parsing, security
features, and edge cases.

Test coverage includes:

Environment detection (3 tests):
- Validation skips when ACI_URL not set
- Validation runs when ACI_URL is set
- Empty string ACI_URL treated as not set

Path-based heuristic (9 tests):
- Matches "defaults" directory component
- Matches defaults.yaml and defaults.yml files
- Rejects non-YAML files (defaults.txt, defaults.json)
- Rejects substring matches (defaultuser, my-defaulted-config)
- Case-insensitive matching for directory names

YAML content validation (8 tests):
- Finds defaults.apic structure in YAML files
- Handles both .yaml and .yml extensions
- Rejects files missing "defaults:" or "apic:" keys
- Handles empty files and unreadable files gracefully

Security features (2 tests):
- Rejects symlinks to prevent path traversal
- Skips files larger than 3MB to prevent memory exhaustion

Performance features (1 test):
- Directory scanning is non-recursive (documented design decision)

Edge cases (9 tests):
- Two-stage validation (path check → content check)
- Multiple data paths with mixed content
- Content check runs as fallback when path check fails

Test quality: Uses real filesystem operations (tmp_path) not mocks,
validates actual application behavior not library functionality,
includes comprehensive docstrings documenting test intent.
Tests display_aci_defaults_banner() function with focus on output
content, NO_COLOR environment variable support, and error handling.

Test coverage includes:

Banner content validation (3 tests):
- Banner displays without raising exceptions
- Banner contains required error message text
- Banner includes example command with correct syntax

NO_COLOR support (2 tests):
- ASCII box style used when NO_COLOR environment variable is set
- Unicode box style used when NO_COLOR is not set

Test approach: Uses pytest's capsys fixture to capture stdout and
validate actual terminal output. Tests verify real banner display
behavior, not mocked components.

Note: Banner tests focus on content validation (error message, example
command) rather than exact formatting (border characters, spacing).
This design allows banner styling to evolve without breaking tests as
long as essential content remains present.
Implements 6 integration tests split into two test classes validating
end-to-end CLI behavior with ACI defaults validation.

TestCliAciValidationIntegration (4 tests using CliRunner):
- Tests validation triggers when ACI_URL set without defaults
- Tests validation skips when ACI_URL not set
- Tests validation passes when defaults directory provided
- Tests validation passes when YAML contains defaults.apic structure

TestCliAciValidationSubprocess (2 tests using subprocess.run):
- Tests actual nac-test CLI process execution with ACI_URL
- Tests CLI subprocess without ACI_URL environment variable
- Marked with skipif when CLI not installed in PATH

Integration test design:

CliRunner tests (lines 26-195):
- Use Typer's CliRunner for fast integration testing
- Mock expensive operations (DataMerger, CombinedOrchestrator)
- Validate CLI wiring: arguments → validators → UI → exit codes
- Test focus: Does validation logic integrate correctly with CLI?

Subprocess tests (lines 197-301):
- Execute real nac-test command in subprocess
- Zero mocking - tests production behavior
- Validate complete process: entry point → imports → execution → exit
- Test focus: Does CLI actually work end-to-end in production?

Test fixtures:
- clean_controller_env: Clears environment variables for isolation
- minimal_test_env: Creates temporary directories and YAML files
- cli_test_env: Subprocess-specific environment setup

Coverage: Tests validate that validation runs at correct point in CLI
flow (after argument parsing, before data merge), displays banner on
failure, returns correct exit codes, and handles environment variables.
Integration tests in test_integration_robot_pabot.py set ACI_URL
environment variable via setup_bogus_controller_env fixture, which
triggers the new ACI defaults validation.

Adding defaults.yaml to test fixtures satisfies the validation check
without modifying test logic or environment setup. The file contains
minimal defaults.apic structure required by the validator.

This fixes 20 failing integration tests that were exiting with code 1
(validation failure) instead of code 0 (success).
Additional fixture directories (data_env, data_list, data_list_chunked,
data_merge) also used by integration tests need defaults.yaml to satisfy
ACI validation when ACI_URL environment variable is set.

Ensures all 20 failing integration tests pass by providing the minimal
defaults.apic structure required by the validator in all test fixture
data directories.
The test_nac_test_render_output_model test passes individual YAML files
(file1.yaml, file2.yaml) via -d flags instead of a directory. Our
validator performs content-based checking on these files.

Adding defaults.apic structure to file1.yaml satisfies the validator's
content check. The expected result.yaml is updated to include the
defaults structure since it's part of the merged data model.

This fixes the 2 remaining failing integration tests.
The test_nac_test_pyats_quicksilver_api_only tests set architecture-specific
URL environment variables (ACI_URL, CC_URL, SDWAN_URL) which trigger the
ACI defaults validation. These fixture directories need minimal defaults.yaml
files with the required structure to satisfy the validation check.

Files added:
- tests/integration/fixtures/data_pyats_qs/aci/defaults.yaml
- tests/integration/fixtures/data_pyats_qs/catc/defaults.yaml
- tests/integration/fixtures/data_pyats_qs/sdwan/defaults.yaml

Each contains minimal mock structure: defaults.apic.version = 6.0

This fixes the failing CI test: test_nac_test_pyats_quicksilver_api_only[aci-ACI-1-0-0]
Updated test files to use new names introduced in Phase 1:
- CONTROLLER_REGISTRY instead of separate dict constants
- extract_host() instead of _extract_host()
- Updated test class and test assertions for dataclass-based config

Added type: ignore comments for untyped Auth class methods to satisfy mypy.

All 64 tests now pass successfully.
Introduces core/http_constants.py as the single source of truth for HTTP
status code range boundaries and special status code sets used throughout
the framework.

Why this improves the codebase:
- Eliminates DRY violations where HTTP status ranges were defined in
  multiple locations (subprocess_client.py, http/__init__.py)
- Provides consistent status code classification logic
- Makes it easier to maintain and update HTTP-related constants
- Improves type safety with explicitly typed constants
- Centralizes authentication failure codes (401, 403) and service
  unavailable codes (408, 429, 503, 504) for reuse

This module serves as a foundation for other refactoring work that will
migrate existing code to use these centralized constants.
Updates subprocess_client.py to import and use HTTP status code constants
from the new core/http_constants.py module instead of defining them locally.

Why this improves the codebase:
- Eliminates duplication of HTTP status range definitions
- Ensures consistent status code classification across the framework
- Makes future updates to HTTP constants affect all consumers automatically
- Improves maintainability by reducing places where constants are defined

This is part of a broader effort to establish core/http_constants.py as
the single source of truth for HTTP-related constants.
Updates the http package's __init__.py to re-export HTTP constants from
core/http_constants.py rather than defining them locally. This maintains
backward compatibility for any code importing from nac_test.pyats_core.http.

Why this improves the codebase:
- Preserves existing import paths for backward compatibility
- Delegates to the single source of truth (core/http_constants.py)
- Reduces maintenance burden by having only one place to update constants
- Makes the http package a thin facade over the core constants module

This change ensures that code using either import path gets the same
constants, preventing potential inconsistencies.
Introduces utils/url.py with extract_host() function to provide robust URL
parsing capabilities using Python's standard library urlparse.

Why this improves the codebase:
- Centralizes URL manipulation logic previously duplicated or ad-hoc
- Uses Python's battle-tested urlparse for robust parsing (handles edge
  cases like missing schemes, IPv6 addresses, port numbers)
- Provides consistent host extraction across the codebase
- Eliminates brittle string manipulation approaches (split('/'), regex)
- Improves testability by isolating URL parsing logic
- Makes the codebase more maintainable by having a single utility for URL operations

This utility will be used by auth_failure.py, controller_auth.py, and
potentially other modules that need to extract host information from URLs.
Introduces core/error_classification.py to extract and centralize
authentication error classification logic. This module provides AuthOutcome
enum and classification functions for HTTP and network errors.

Why this improves the codebase:
- Separates error classification logic from controller authentication logic
  (Single Responsibility Principle)
- Makes error classification logic highly testable in isolation
- Uses a two-tier classification strategy: check network failures first
  (timeouts, connection refused, DNS) to avoid false positives from port
  numbers being matched as HTTP status codes
- Provides structured outcome classification via AuthOutcome enum instead
  of raw strings
- Centralizes HTTP status code interpretation logic (401/403 → bad
  credentials, 408/429/503/504 → unreachable, etc.)
- Uses regex for reliable HTTP status code extraction from error messages
- Improves maintainability by having one place to update classification rules

This module will be used by controller_auth.py and auth_failure.py to
provide consistent error categorization across the framework.
Adds ControllerConfig dataclass and CONTROLLER_REGISTRY to centralize
controller metadata, along with two new helper functions:
get_display_name() and get_env_var_prefix().

Why this improves the codebase:
- Eliminates DRY violations where controller display names and env var
  prefixes were hardcoded in 5+ locations across the codebase
- Provides a single source of truth (CONTROLLER_REGISTRY) for all
  controller metadata (display names, URL env vars, credential patterns)
- Introduces ControllerConfig dataclass for type-safe controller metadata
- Makes controller information easily accessible via simple helper functions
- Improves maintainability: adding new controllers or changing display
  names now requires updates in only one place
- Preserves backward compatibility by maintaining CREDENTIAL_PATTERNS as
  a view over CONTROLLER_REGISTRY

The helper functions gracefully degrade by returning the controller_type
string if a controller is not in the registry, preventing crashes when
dealing with unknown controller types.
Fixes the ColorValue type annotation in banners.py (was incorrectly using
int | tuple, now correctly str | int | tuple) and extracts common banner
rendering logic into a reusable _render_banner() function.

Why this improves the codebase:
- Corrects type annotation to match typer's actual color value type (must
  include str for named colors like "red", "white")
- Eliminates massive code duplication across banner display functions by
  extracting shared rendering logic into _render_banner()
- Reduces line count by ~100+ lines while preserving all functionality
- Makes banner rendering logic testable in isolation
- Improves maintainability: changes to border rendering now affect all
  banners automatically
- Uses controller helper functions (get_display_name, extract_host) to
  eliminate hardcoded controller names
- Follows DRY principle by having a single implementation of box-drawing
  character handling and color application

All public banner functions now delegate to _render_banner() with
appropriate title, content, and color parameters.
Updates main.py to use get_display_name() and get_env_var_prefix() from
utils.controller instead of hardcoding controller names and prefixes.

Why this improves the codebase:
- Eliminates hardcoded controller display names (no more "SDWAN Manager"
  string literals scattered throughout)
- Reduces coupling between main.py and controller-specific knowledge
- Makes the code more maintainable: adding/changing controller names now
  only requires updates to CONTROLLER_REGISTRY
- Improves consistency by ensuring all code uses the same display names
- Follows DRY principle by delegating to centralized helper functions

This is part of a broader effort to eliminate controller metadata
duplication across the codebase.
Updates auth_failure.py to use get_display_name() from utils.controller
and extract_host() from utils.url instead of defining logic inline.

Why this improves the codebase:
- Eliminates hardcoded controller display names
- Replaces brittle URL host extraction (split('/')) with robust urlparse
- Reduces coupling between auth_failure.py and controller-specific logic
- Improves testability by delegating to tested utility functions
- Makes the code more maintainable and consistent with the rest of the
  codebase
- Follows DRY principle by reusing centralized utilities

This change ensures auth_failure.py uses the same display names and URL
parsing logic as the rest of the framework.
Updates test_controller_auth.py to reflect the refactored module structure
where error classification and controller registry moved to separate modules.

Why this improves the test suite:
- Removes tests for AuthOutcome enum (now tested in test_error_classification.py)
- Removes tests for AuthCheckResult dataclass (simple dataclass, no logic)
- Removes tests for ControllerConfig (now tested in test_controller.py)
- Focuses tests on the actual validation logic in controller_auth.py
- Follows the principle of testing behavior, not implementation details
- Imports CONTROLLER_REGISTRY and AuthOutcome from their new locations
- Maintains test coverage of the actual authentication validation flow

This change aligns the test suite with the refactored module boundaries,
ensuring each module's tests focus on that module's responsibilities.
Updates test_banners.py to test the new _render_banner() helper function
and ensures all banner functions still work correctly after refactoring.

Why this improves the test suite:
- Tests the extracted _render_banner() function directly to verify common
  banner rendering logic
- Ensures all public banner functions still produce correct output after
  delegating to _render_banner()
- Maintains coverage of NO_COLOR environment variable handling
- Tests that controller helper functions are used correctly (get_display_name)
- Follows DRY principle in tests by verifying shared logic once rather
  than duplicating assertions across multiple banner function tests

This change ensures the refactored banner code maintains the same
behavior while having better test isolation for the extracted logic.
Updates test_auth_failure.py to reflect that auth_failure.py now imports
display names and URL extraction from centralized utility modules.

Why this improves the test suite:
- Aligns test imports with the refactored module structure
- Tests continue to verify that auth_failure.py uses correct display
  names and URL extraction logic
- Ensures coverage of the integration between auth_failure.py and the
  new utility functions (get_display_name, extract_host)
- Maintains test isolation while verifying correct delegation to utilities

This change ensures auth_failure.py tests remain accurate after the
refactoring to use centralized utilities.
Moves test_cli_controller_auth.py from tests/integration/ to tests/unit/cli/
to reflect its actual testing scope and organization.

Why this improves the test suite:
- Places test file in the correct directory structure matching the module
  it tests (cli/validators/controller_auth.py)
- Improves test discoverability: unit tests for cli modules should be
  under tests/unit/cli/
- Separates unit tests from integration tests more clearly
- Follows the convention of mirroring the source tree structure in the
  test directory
- Makes it easier for developers to find relevant tests

This is a pure organizational change with no logic modifications.
@aitestino aitestino self-assigned this Feb 15, 2026
@aitestino aitestino force-pushed the feature/preflight-controller-auth branch from a2161a4 to 6e8fa7b Compare February 16, 2026 15:57
Dry-run mode doesn't need controller access, same as render-only.
Gate both detect_controller_type() and preflight_auth_check() behind
`not render_only and not dry_run` in main() and CombinedOrchestrator.
The integration tests use bogus ACI credentials (ACI_URL=foo) which now
trigger the preflight auth check added in the previous commit. Since
these tests validate Robot rendering/execution/ordering behavior (not
authentication), mock both detect_controller_type and preflight_auth_check
in the existing setup_bogus_controller_env autouse fixtures.
filelock 3.24.2 (UnixFileLock) auto-deletes .lock files on release,
so the lock file does not persist between separate FileLock acquisitions.
The test incorrectly asserted .lock file existence after _cache_auth_data()
returned — this was testing filelock library behavior, not our code.

Updated to verify the end state: no cache or lock artifacts remain after
invalidation, regardless of filelock's cleanup behavior.
@aitestino aitestino force-pushed the feature/preflight-controller-auth branch from 0cc5d18 to 7710b61 Compare March 6, 2026 03:11
@danischm
Copy link
Copy Markdown
Member

danischm commented Mar 6, 2026

The current (1.x) implementation of nac-test is a deliberately very generic testing "engine" (jinja rendering + pabot execution), that is not necessarily limited to infra testing, eg. you could use it for example to just query some assurance system (ThousandEyes) and not touch any infra component at all. If possible I would prefer to retain that "genericness" for the robot path going forward. On the other hand the new Pyats path requires architecture specific components to be in place, where it makes perfect sense to introduce such preflight checks. Therefore I would suggest to introduce those as early as possible in the Pyats path, but preferably not on the Robot path if that makes sense.

@oboehmer
Copy link
Copy Markdown
Collaborator

oboehmer commented Mar 6, 2026

Thanks for the additional perspective, @danischm , this has been also my thinking. I feel we should avoid breaking existing robot test case executions (even if this is a major release upgrade). I have already started to work on #613 (draft, still need to review test coverage, will have it ready Monday), this one moves the controller check into PyATSOrchestrator.run_tests(), and as part of it I also plan to remove the environment setup to all robot-only tests (#431)

Happy to revisit this for robot in the future, but for now I would want to behave in a backward-compatible way and not risk breaking existing deployments.

@aitestino
Copy link
Copy Markdown
Collaborator Author

aitestino commented Mar 6, 2026

Sure @danischm @oboehmer I understand the perspective and respect everyone's opinions. I shall refactor to meet this POV.

P.S: @oboehmer Im pretty sure once I address this feedback in this PR, it would invalidate your #613 because it would result in the same? Would hate for you to spend extra cycles so just wanted to highlight that.

@oboehmer
Copy link
Copy Markdown
Collaborator

oboehmer commented Mar 6, 2026

P.S: Im pretty sure once I address this feedback in this PR, it would invalidate your #613 because it would result in the same? Would hate for you to spend extra cycles so just wanted to highlight that.

Fair comment, thanks @aitestino ! alternatively you could just move the credential checks to pyats controller (still assume you get a valid controller_type passed) and I will do the rest, I might have more cycles, but will leave it up to you. I can do some work on 613 over the weekend..

@danischm
Copy link
Copy Markdown
Member

danischm commented Mar 6, 2026

Nevertheless a "fail early" option for the Robot path would be beneficial. I would suggest exploring if we can add those checks to the "Suite Setup" in Robot (for each technology).

@oboehmer
Copy link
Copy Markdown
Collaborator

oboehmer commented Mar 6, 2026

Nevertheless a "fail early" option for the Robot path would be beneficial. I would suggest exploring if we can add those checks to the "Suite Setup" in Robot (for each technology).

100%. I would look at contributing a „Fatal Pabot Error“ keyword which would abandon all executions, not only the current suite.

@aitestino
Copy link
Copy Markdown
Collaborator Author

Nevertheless a "fail early" option for the Robot path would be beneficial. I would suggest exploring if we can add those checks to the "Suite Setup" in Robot (for each technology).

For sure. Seems more sensible to include the functionality for Robot just in a diff way :) I'll take all the above into account and refactor tomorrow so we have it ready this Monday. Thank you both!

@oboehmer
Copy link
Copy Markdown
Collaborator

oboehmer commented Mar 8, 2026

@aitestino , please have a look at #613 where I coded how I would tackle the controller detection.. I also adjusted the summary report to show the exceptions..

The nac-test CLI should remain a generic testing engine (Jinja + pabot)
that isn't tied to infrastructure controllers. This moves controller
detection and preflight auth validation from main.py into
CombinedOrchestrator.run_tests(), gated by has_pyats and not
render_only/dry_run.

Changes:
- Remove controller detection and auth check block from cli/main.py
- Add preflight auth check to CombinedOrchestrator.run_tests()
- Consolidate controller detection into run_tests() flow
- Add shared integration test conftest with setup_bogus_controller_env
- Use pytest.mark.usefixtures for selective fixture application
- Remove redundant auth mocks from unit tests (CombinedOrchestrator
  is fully mocked via @patch)
- Update test assertions for new orchestrator-level auth handling
Dry-run mode validates test structure, not execution — it should not
require controller credentials. The auth gate in run_tests() now
checks `not self.dry_run` alongside `not self.render_only`.
@aitestino
Copy link
Copy Markdown
Collaborator Author

Hey @oboehmer — circling back to our discussion from Friday.

After Daniel's feedback, my understanding was that we aligned on addressing the core concern in #531 first (decoupling the CLI from controller-specific logic and moving the preflight/auth handling into the orchestrator layer), and that you would hold off on #613 while I worked through those changes.

I've now pushed the updated work to #531, incorporating both your and Daniel's feedback. The main changes are:

  • CLI remains generic
  • Controller detection and auth moved into CombinedOrchestrator.run_tests()
  • Integration test fixtures consolidated

Looking at #613, it appears work continued there in parallel and there is now meaningful overlap between the two PRs. For example:

  • Both move controller detection from __init__ to run_tests()
  • Both touch the integration test fixtures
  • Both adjust the combined HTML report path through different mechanisms

To avoid unnecessary merge conflicts and duplicated work, we should follow the sequencing we discussed:

  1. Re-review and merge feat: implement fail-fast authentication #531 with the updated changes
  2. Rebase feat: enable Robot execution without controller credentials (#612) #613 on top of it
  3. Keep the pieces in feat: enable Robot execution without controller credentials (#612) #613 that are genuinely additive (e.g. ControllerDetectionError, verbose_message on TestResults, etc.)

Since #531 now addresses the architectural concerns Daniel raised, merging it first gives #613 a clean base to build on and keeps the implementation coherent.

Please take another look at #531 with the latest updates so we can move this forward.

P.S. — This comment was drafted using voice-to-text via Claude Code. If the tone comes across as overly direct or terse, please know that's just how it tends to phrase things. No offense or criticism is intended — this is purely an objective technical review of the PR. Thanks for understanding! 🙂

@aitestino aitestino requested a review from oboehmer March 9, 2026 02:38
@oboehmer
Copy link
Copy Markdown
Collaborator

oboehmer commented Mar 9, 2026

Thanks for the refactoring, @aitestino , will take a look right away.. I should have marked 613 as Draft, but as I didn't get a clear response to my "alternatively you could just move the credential checks to pyats controller (still assume you get a valid controller_type passed) and I will do the rest", I went ahead, but I will happily rebase on your work, which includes some great refactoring I can leverage for reporting. This will also give us more time to discuss how we handle and report failures in a mixed pyats/robot scenario where pyats pre-flight checks fail.

Copy link
Copy Markdown
Collaborator

@oboehmer oboehmer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for the implementation and all the cleanup/refactoring part of it. I have a couple of comments, but no blockers for alpha, I can address them also part of my follow-up 613..

Comment on lines +136 to +138
Security:
- Rejects symlinks to prevent reading outside intended directories

Copy link
Copy Markdown
Collaborator

@oboehmer oboehmer Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not critical, but which security implications are you concerned with here? nac-test Typer() happily accepts directories which are symlinks as arguments, so we at least create some inconsistency (and possibly backward-incompatible behavior in case folks already make use of symlinks in their setup)?

Comment on lines +188 to +197
except ValueError as e:
# Missing environment variables - let the actual auth fail later with proper error
logger.debug("Pre-flight auth check skipped due to missing env vars: %s", e)
return AuthCheckResult(
success=True,
reason=AuthOutcome.SUCCESS,
controller_type=controller_type,
controller_url=controller_url,
detail=f"Pre-flight check skipped: {e}",
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this redundant, or did you add this as defensive programming? We already checked the environment during controller detection, so we should never end up here?


from nac_test.core.error_classification import (
AuthOutcome,
_classify_auth_error,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't _classify_auth_error become a public function if we import it across modules?

Comment thread nac_test/cli/main.py
Comment on lines +331 to +332
typer.echo(
f"[{end_timestamp}] ✅ Data model merging completed ({format_duration(duration)})"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are using timestamped output inconsistently in main/combined_orchestrator. would suggest we drop the [{timestamp}] pattern in our typer.echo, and, through a new PR, enhance the logger console formatter to print timestamps, so users can see them there if they care.

Comment thread nac_test/cli/main.py
Comment on lines +397 to +398
if stats.pre_flight_failure is not None:
raise typer.Exit(stats.exit_code)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's review as part of #613 discussion on how to deal with reporting pre-flight failures.


# Single source of truth for all controller configurations
# Replaces both CREDENTIAL_PATTERNS and the registry from controller_auth.py
CONTROLLER_REGISTRY: dict[str, ControllerConfig] = {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

love it, thanks for introducing this!!

return message


def get_display_name(controller_type: str) -> str:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looking at these functions, I thought about introducing a proper Controller class with properties we can pass around, but let's leave this for later.

Comment thread nac_test/utils/url.py

extract_host("http://10.1.2.3")
# Returns: '10.1.2.3'

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ipv6 literals are not parsed correctly here?

>>> from nac_test.utils.url import extract_host
>>> extract_host('https://[2001:db8::1]')
'[2001:db8::1]'
>>> 

fg=typer.colors.RED,
err=True,
)
raise typer.Exit(EXIT_ERROR) from None
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's review this as part of 613.. I feel we should/can continue to execute robot, but fine for the alpha release

pytestmark = pytest.mark.integration
pytestmark = [
pytest.mark.integration,
pytest.mark.usefixtures("setup_bogus_controller_env"),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical: this can and must go away. these tests are robot-only, and should/must not use any controller setup. This comment also applies to the other two test_cli tests.

Comment thread nac_test/core/types.py
@aitestino
Copy link
Copy Markdown
Collaborator Author

Hey @oboehmer Thank you so much! Ack on your comments, agreed OK for later (I'll make sure I open issues so we don't forget). Just so its documented somewhere (and great callout) also, here were my thoughts on internal chat about what to represent stat wise:

"""This is a reasonable UX question.

The issue is that the combined summary HTML has a "Success Rate" at the top (e.g. "85%" with a progress bar), calculated from total tests passed / total tests run across all frameworks (Robot + PyATS API + PyATS D2D). So your question is, when PyATS auth fails but Robot succeeds, what number do we put there? What would make the most sense to our customers/engineers?

As I see it, the options are:
1. Show instead of a % because we genuinely don't know how many PyATS tests would have run, so any percentage would be misleading like you said.
2. Count the number of PyATS .py test files and report them as failed but that's not really accurate either, since one file can contain many test cases (ideally not but could happen), and D2D tests scale with the number of devices in the environment etc
3. Add a "Pre-Flight Failure" label in the box where it normally shows the failure count.

I thiiiiiink is the least misleading of the three and also KISS lol. We can't calculate a meaningful success rate when half the test suite never ran. Showing a number..any number..implies we know something we don't."""

Merge base branch incorporating PR #599 (logging refactor: VerbosityLevel
-> LogLevel) and PR #554 (dry-run support). Conflict resolution:

- Import merges: combined both our auth-related imports (get_env_var_prefix,
  AUTH_SUCCESS) with the new logging imports (LogLevel, DEFAULT_LOGLEVEL)
- test_main_exit_codes.py: adopted upstream parametrized test structure,
  added PreFlightFailure test case as additional parametrize entry
- Incidental conflicts (subprocess_runner, batching_reporter, robot
  orchestrator): took upstream changes
@aitestino aitestino merged commit 7cc29b6 into release/pyats-integration-v1.1-beta Mar 10, 2026
7 checks passed
@aitestino
Copy link
Copy Markdown
Collaborator Author

Hey @oboehmer — also opened tracking issues for all the non-blocking items you flagged so nothing falls through the cracks:

Parent: #625 — Follow-up improvements from preflight auth check implementation

If you end up addressing any of these in #613, just link them and I'll close the corresponding issue. Otherwise we can tackle them later — just didn't want to forget. Thanks again!

P.S. — This comment was drafted using voice-to-text via Claude Code. If the tone comes across as overly direct or terse, please know that's just how it tends to phrase things. No offense or criticism is intended — this is purely an objective technical review of the PR. Thanks for understanding! 🙂

@aitestino aitestino deleted the feature/preflight-controller-auth branch March 11, 2026 04:15
aitestino added a commit that referenced this pull request Mar 11, 2026
…ution-in-base-class-v2

Brings in critical upstream improvements from v2.0.0a1 release:
- Fail-fast authentication validation (#531)
- Dry-run support for PyATS (#554)
- Clean up stale test artifacts (#526, #571)
- Improved CLI with --verbose and --loglevel flags (#599)
- Better test isolation for parallel execution (#569)
- GitHub templates for issues and PRs (#510)
- Multiple dependency updates and bug fixes

These changes provide the test infrastructure improvements needed for
the defaults resolution feature, particularly the controller auth
validation that addresses test fixture concerns.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
oboehmer added a commit that referenced this pull request Mar 19, 2026
* Add shared architecture detection helper for validators

Introduces is_architecture_active() helper function that provides a
lightweight check for whether an architecture environment is active by
testing if its URL environment variable is set.

This helper is shared across all architecture-specific validators (ACI,
SD-WAN, Catalyst Center) to avoid duplicating environment detection logic.

Rationale: Validators need to know which architecture is active to decide
whether to perform validation. Centralizing this check in common.py ensures
consistency and makes adding new architecture validators straightforward.

* Add ACI defaults validation with two-stage heuristic

Implements validate_aci_defaults() that fails fast when users forget to
pass the ACI defaults file, catching the common mistake of omitting
-d ./defaults/ before expensive data merge operations.

Two-stage validation approach:

Stage 1 (Instant path-based heuristic):
- Checks if path contains "default" or "defaults" as directory component
- For files: requires .yaml/.yml extension + "default" in filename
- Rejects false positives like "/users/defaultuser/" or "config.txt"

Stage 2 (Content-based fallback):
- Parses YAML files to find "defaults.apic" structure
- Non-recursive directory scanning for performance
- Handles both .yaml and .yml extensions

Security features:
- Rejects symlinks to prevent path traversal
- Limits file scanning to 3MB to prevent memory exhaustion

Performance: Path heuristic provides instant feedback for 95% of cases.
Content scanning only runs when path-based check doesn't match, adding
minimal overhead (~50-100ms) compared to time saved when defaults are
missing (users get immediate feedback instead of waiting for full merge).

* Add validators package public API exports

Exports validate_aci_defaults() and is_architecture_active() as the
public API for the validators package.

Enforces separation of concerns: validators package only exports
validation functions, not UI components. Display functions like
display_aci_defaults_banner() should be imported directly from
nac_test.cli.ui, maintaining clear architectural boundaries between
validation logic and presentation.

This design allows adding new architecture validators (SD-WAN, Catalyst
Center) by following the same pattern without coupling to UI concerns.

* Add terminal banner display for ACI defaults error

Implements display_aci_defaults_banner() to show a prominent, user-friendly
error message when ACI defaults file is missing.

BoxStyle dataclass design:
- Encapsulates box-drawing characters for terminal rendering
- Frozen dataclass ensures immutability
- Supports both ASCII (fallback) and Unicode (modern terminals)
- Includes emoji_adjustment field to handle Unicode width quirks

NO_COLOR environment variable support:
- Respects NO_COLOR standard for accessibility
- Falls back to plain ASCII when NO_COLOR is set or Unicode unavailable

Banner content includes:
- Clear error message explaining the requirement
- Working example command showing correct syntax
- Link to Cisco ACI documentation for context
- Professional formatting with borders and sections

Rationale: Validation errors need to be immediately visible and actionable.
A prominent banner with example command helps users fix the issue quickly
instead of hunting through error logs.

* Add UI package public API for banner display

Exports display_aci_defaults_banner() as the public interface for CLI
error banners. Future banner functions (SD-WAN, Catalyst Center) will
be exported from this package.

Architectural separation: UI package handles all user-facing display
logic, while validators package handles business logic. This separation
allows testing and modifying display behavior independently from
validation rules.

* Wire ACI defaults validation into CLI entry point

Adds validation check immediately before DataMerger.merge_data_files()
to fail fast when defaults are missing. Validation runs after output
directory creation but before expensive merge operation.

Execution flow:
1. Parse CLI arguments
2. Configure logging
3. Create output directory
4. → NEW: Validate ACI defaults (instant check)
5. Merge data files (expensive operation, 2-5 seconds)
6. Continue with orchestrator...

Error handling:
- Validation failure displays prominent banner via display_aci_defaults_banner()
- Exits with code 1 (failure) to prevent continuing with invalid config
- Blank lines before/after banner improve readability

Time savings: Users get immediate feedback instead of waiting 2-5 seconds
for data merge to complete before seeing validation error. Pre-flight check
philosophy: validate prerequisites before expensive operations.

* Add test directory structure for CLI components

Creates package structure mirroring source code organization:
- tests/unit/cli/validators/ for validator unit tests
- tests/unit/cli/ui/ for UI component unit tests

Empty __init__.py files make directories importable Python packages,
allowing pytest to discover tests and enabling relative imports within
test modules.

Test organization principle: Test structure should mirror source
structure. This makes finding tests for a given module intuitive and
maintains clear architectural boundaries in both source and test code.

* Add comprehensive unit tests for ACI defaults validator

Tests validate_aci_defaults() and helper functions with 32 test cases
covering environment detection, path matching, YAML parsing, security
features, and edge cases.

Test coverage includes:

Environment detection (3 tests):
- Validation skips when ACI_URL not set
- Validation runs when ACI_URL is set
- Empty string ACI_URL treated as not set

Path-based heuristic (9 tests):
- Matches "defaults" directory component
- Matches defaults.yaml and defaults.yml files
- Rejects non-YAML files (defaults.txt, defaults.json)
- Rejects substring matches (defaultuser, my-defaulted-config)
- Case-insensitive matching for directory names

YAML content validation (8 tests):
- Finds defaults.apic structure in YAML files
- Handles both .yaml and .yml extensions
- Rejects files missing "defaults:" or "apic:" keys
- Handles empty files and unreadable files gracefully

Security features (2 tests):
- Rejects symlinks to prevent path traversal
- Skips files larger than 3MB to prevent memory exhaustion

Performance features (1 test):
- Directory scanning is non-recursive (documented design decision)

Edge cases (9 tests):
- Two-stage validation (path check → content check)
- Multiple data paths with mixed content
- Content check runs as fallback when path check fails

Test quality: Uses real filesystem operations (tmp_path) not mocks,
validates actual application behavior not library functionality,
includes comprehensive docstrings documenting test intent.

* Add unit tests for ACI defaults banner display

Tests display_aci_defaults_banner() function with focus on output
content, NO_COLOR environment variable support, and error handling.

Test coverage includes:

Banner content validation (3 tests):
- Banner displays without raising exceptions
- Banner contains required error message text
- Banner includes example command with correct syntax

NO_COLOR support (2 tests):
- ASCII box style used when NO_COLOR environment variable is set
- Unicode box style used when NO_COLOR is not set

Test approach: Uses pytest's capsys fixture to capture stdout and
validate actual terminal output. Tests verify real banner display
behavior, not mocked components.

Note: Banner tests focus on content validation (error message, example
command) rather than exact formatting (border characters, spacing).
This design allows banner styling to evolve without breaking tests as
long as essential content remains present.

* Add integration tests for CLI ACI defaults validation

Implements 6 integration tests split into two test classes validating
end-to-end CLI behavior with ACI defaults validation.

TestCliAciValidationIntegration (4 tests using CliRunner):
- Tests validation triggers when ACI_URL set without defaults
- Tests validation skips when ACI_URL not set
- Tests validation passes when defaults directory provided
- Tests validation passes when YAML contains defaults.apic structure

TestCliAciValidationSubprocess (2 tests using subprocess.run):
- Tests actual nac-test CLI process execution with ACI_URL
- Tests CLI subprocess without ACI_URL environment variable
- Marked with skipif when CLI not installed in PATH

Integration test design:

CliRunner tests (lines 26-195):
- Use Typer's CliRunner for fast integration testing
- Mock expensive operations (DataMerger, CombinedOrchestrator)
- Validate CLI wiring: arguments → validators → UI → exit codes
- Test focus: Does validation logic integrate correctly with CLI?

Subprocess tests (lines 197-301):
- Execute real nac-test command in subprocess
- Zero mocking - tests production behavior
- Validate complete process: entry point → imports → execution → exit
- Test focus: Does CLI actually work end-to-end in production?

Test fixtures:
- clean_controller_env: Clears environment variables for isolation
- minimal_test_env: Creates temporary directories and YAML files
- cli_test_env: Subprocess-specific environment setup

Coverage: Tests validate that validation runs at correct point in CLI
flow (after argument parsing, before data merge), displays banner on
failure, returns correct exit codes, and handles environment variables.

* Fix CI: Add defaults.yaml to integration test fixtures

Integration tests in test_integration_robot_pabot.py set ACI_URL
environment variable via setup_bogus_controller_env fixture, which
triggers the new ACI defaults validation.

Adding defaults.yaml to test fixtures satisfies the validation check
without modifying test logic or environment setup. The file contains
minimal defaults.apic structure required by the validator.

This fixes 20 failing integration tests that were exiting with code 1
(validation failure) instead of code 0 (success).

* Fix CI: Add defaults.yaml to all integration test fixture directories

Additional fixture directories (data_env, data_list, data_list_chunked,
data_merge) also used by integration tests need defaults.yaml to satisfy
ACI validation when ACI_URL environment variable is set.

Ensures all 20 failing integration tests pass by providing the minimal
defaults.apic structure required by the validator in all test fixture
data directories.

* Fix CI: Add defaults.apic structure to data_merge test fixtures

The test_nac_test_render_output_model test passes individual YAML files
(file1.yaml, file2.yaml) via -d flags instead of a directory. Our
validator performs content-based checking on these files.

Adding defaults.apic structure to file1.yaml satisfies the validator's
content check. The expected result.yaml is updated to include the
defaults structure since it's part of the merged data model.

This fixes the 2 remaining failing integration tests.

* Add defaults.yaml to PyATS quicksilver test fixtures

The test_nac_test_pyats_quicksilver_api_only tests set architecture-specific
URL environment variables (ACI_URL, CC_URL, SDWAN_URL) which trigger the
ACI defaults validation. These fixture directories need minimal defaults.yaml
files with the required structure to satisfy the validation check.

Files added:
- tests/integration/fixtures/data_pyats_qs/aci/defaults.yaml
- tests/integration/fixtures/data_pyats_qs/catc/defaults.yaml
- tests/integration/fixtures/data_pyats_qs/sdwan/defaults.yaml

Each contains minimal mock structure: defaults.apic.version = 6.0

This fixes the failing CI test: test_nac_test_pyats_quicksilver_api_only[aci-ACI-1-0-0]

* fix(tests): update test imports for Phase 1 refactoring

Updated test files to use new names introduced in Phase 1:
- CONTROLLER_REGISTRY instead of separate dict constants
- extract_host() instead of _extract_host()
- Updated test class and test assertions for dataclass-based config

Added type: ignore comments for untyped Auth class methods to satisfy mypy.

All 64 tests now pass successfully.

* refactor: create centralized HTTP status constants module

Introduces core/http_constants.py as the single source of truth for HTTP
status code range boundaries and special status code sets used throughout
the framework.

Why this improves the codebase:
- Eliminates DRY violations where HTTP status ranges were defined in
  multiple locations (subprocess_client.py, http/__init__.py)
- Provides consistent status code classification logic
- Makes it easier to maintain and update HTTP-related constants
- Improves type safety with explicitly typed constants
- Centralizes authentication failure codes (401, 403) and service
  unavailable codes (408, 429, 503, 504) for reuse

This module serves as a foundation for other refactoring work that will
migrate existing code to use these centralized constants.

* refactor: migrate subprocess_client.py to centralized HTTP constants

Updates subprocess_client.py to import and use HTTP status code constants
from the new core/http_constants.py module instead of defining them locally.

Why this improves the codebase:
- Eliminates duplication of HTTP status range definitions
- Ensures consistent status code classification across the framework
- Makes future updates to HTTP constants affect all consumers automatically
- Improves maintainability by reducing places where constants are defined

This is part of a broader effort to establish core/http_constants.py as
the single source of truth for HTTP-related constants.

* refactor: update http/__init__.py to re-export centralized constants

Updates the http package's __init__.py to re-export HTTP constants from
core/http_constants.py rather than defining them locally. This maintains
backward compatibility for any code importing from nac_test.pyats_core.http.

Why this improves the codebase:
- Preserves existing import paths for backward compatibility
- Delegates to the single source of truth (core/http_constants.py)
- Reduces maintenance burden by having only one place to update constants
- Makes the http package a thin facade over the core constants module

This change ensures that code using either import path gets the same
constants, preventing potential inconsistencies.

* refactor: create URL parsing utility module

Introduces utils/url.py with extract_host() function to provide robust URL
parsing capabilities using Python's standard library urlparse.

Why this improves the codebase:
- Centralizes URL manipulation logic previously duplicated or ad-hoc
- Uses Python's battle-tested urlparse for robust parsing (handles edge
  cases like missing schemes, IPv6 addresses, port numbers)
- Provides consistent host extraction across the codebase
- Eliminates brittle string manipulation approaches (split('/'), regex)
- Improves testability by isolating URL parsing logic
- Makes the codebase more maintainable by having a single utility for URL operations

This utility will be used by auth_failure.py, controller_auth.py, and
potentially other modules that need to extract host information from URLs.

* refactor: create error classification module

Introduces core/error_classification.py to extract and centralize
authentication error classification logic. This module provides AuthOutcome
enum and classification functions for HTTP and network errors.

Why this improves the codebase:
- Separates error classification logic from controller authentication logic
  (Single Responsibility Principle)
- Makes error classification logic highly testable in isolation
- Uses a two-tier classification strategy: check network failures first
  (timeouts, connection refused, DNS) to avoid false positives from port
  numbers being matched as HTTP status codes
- Provides structured outcome classification via AuthOutcome enum instead
  of raw strings
- Centralizes HTTP status code interpretation logic (401/403 → bad
  credentials, 408/429/503/504 → unreachable, etc.)
- Uses regex for reliable HTTP status code extraction from error messages
- Improves maintainability by having one place to update classification rules

This module will be used by controller_auth.py and auth_failure.py to
provide consistent error categorization across the framework.

* refactor: add controller metadata helpers and structured config

Adds ControllerConfig dataclass and CONTROLLER_REGISTRY to centralize
controller metadata, along with two new helper functions:
get_display_name() and get_env_var_prefix().

Why this improves the codebase:
- Eliminates DRY violations where controller display names and env var
  prefixes were hardcoded in 5+ locations across the codebase
- Provides a single source of truth (CONTROLLER_REGISTRY) for all
  controller metadata (display names, URL env vars, credential patterns)
- Introduces ControllerConfig dataclass for type-safe controller metadata
- Makes controller information easily accessible via simple helper functions
- Improves maintainability: adding new controllers or changing display
  names now requires updates in only one place
- Preserves backward compatibility by maintaining CREDENTIAL_PATTERNS as
  a view over CONTROLLER_REGISTRY

The helper functions gracefully degrade by returning the controller_type
string if a controller is not in the registry, preventing crashes when
dealing with unknown controller types.

* refactor: fix type annotation and extract banner rendering logic

Fixes the ColorValue type annotation in banners.py (was incorrectly using
int | tuple, now correctly str | int | tuple) and extracts common banner
rendering logic into a reusable _render_banner() function.

Why this improves the codebase:
- Corrects type annotation to match typer's actual color value type (must
  include str for named colors like "red", "white")
- Eliminates massive code duplication across banner display functions by
  extracting shared rendering logic into _render_banner()
- Reduces line count by ~100+ lines while preserving all functionality
- Makes banner rendering logic testable in isolation
- Improves maintainability: changes to border rendering now affect all
  banners automatically
- Uses controller helper functions (get_display_name, extract_host) to
  eliminate hardcoded controller names
- Follows DRY principle by having a single implementation of box-drawing
  character handling and color application

All public banner functions now delegate to _render_banner() with
appropriate title, content, and color parameters.

* refactor: migrate main.py to use controller helper functions

Updates main.py to use get_display_name() and get_env_var_prefix() from
utils.controller instead of hardcoding controller names and prefixes.

Why this improves the codebase:
- Eliminates hardcoded controller display names (no more "SDWAN Manager"
  string literals scattered throughout)
- Reduces coupling between main.py and controller-specific knowledge
- Makes the code more maintainable: adding/changing controller names now
  only requires updates to CONTROLLER_REGISTRY
- Improves consistency by ensuring all code uses the same display names
- Follows DRY principle by delegating to centralized helper functions

This is part of a broader effort to eliminate controller metadata
duplication across the codebase.

* refactor: migrate auth_failure.py to use centralized utilities

Updates auth_failure.py to use get_display_name() from utils.controller
and extract_host() from utils.url instead of defining logic inline.

Why this improves the codebase:
- Eliminates hardcoded controller display names
- Replaces brittle URL host extraction (split('/')) with robust urlparse
- Reduces coupling between auth_failure.py and controller-specific logic
- Improves testability by delegating to tested utility functions
- Makes the code more maintainable and consistent with the rest of the
  codebase
- Follows DRY principle by reusing centralized utilities

This change ensures auth_failure.py uses the same display names and URL
parsing logic as the rest of the framework.

* test: update controller_auth tests for new module structure

Updates test_controller_auth.py to reflect the refactored module structure
where error classification and controller registry moved to separate modules.

Why this improves the test suite:
- Removes tests for AuthOutcome enum (now tested in test_error_classification.py)
- Removes tests for AuthCheckResult dataclass (simple dataclass, no logic)
- Removes tests for ControllerConfig (now tested in test_controller.py)
- Focuses tests on the actual validation logic in controller_auth.py
- Follows the principle of testing behavior, not implementation details
- Imports CONTROLLER_REGISTRY and AuthOutcome from their new locations
- Maintains test coverage of the actual authentication validation flow

This change aligns the test suite with the refactored module boundaries,
ensuring each module's tests focus on that module's responsibilities.

* test: update banners tests for extracted rendering logic

Updates test_banners.py to test the new _render_banner() helper function
and ensures all banner functions still work correctly after refactoring.

Why this improves the test suite:
- Tests the extracted _render_banner() function directly to verify common
  banner rendering logic
- Ensures all public banner functions still produce correct output after
  delegating to _render_banner()
- Maintains coverage of NO_COLOR environment variable handling
- Tests that controller helper functions are used correctly (get_display_name)
- Follows DRY principle in tests by verifying shared logic once rather
  than duplicating assertions across multiple banner function tests

This change ensures the refactored banner code maintains the same
behavior while having better test isolation for the extracted logic.

* test: update auth_failure tests for new utility imports

Updates test_auth_failure.py to reflect that auth_failure.py now imports
display names and URL extraction from centralized utility modules.

Why this improves the test suite:
- Aligns test imports with the refactored module structure
- Tests continue to verify that auth_failure.py uses correct display
  names and URL extraction logic
- Ensures coverage of the integration between auth_failure.py and the
  new utility functions (get_display_name, extract_host)
- Maintains test isolation while verifying correct delegation to utilities

This change ensures auth_failure.py tests remain accurate after the
refactoring to use centralized utilities.

* test: move test_cli_controller_auth.py to correct directory

Moves test_cli_controller_auth.py from tests/integration/ to tests/unit/cli/
to reflect its actual testing scope and organization.

Why this improves the test suite:
- Places test file in the correct directory structure matching the module
  it tests (cli/validators/controller_auth.py)
- Improves test discoverability: unit tests for cli modules should be
  under tests/unit/cli/
- Separates unit tests from integration tests more clearly
- Follows the convention of mirroring the source tree structure in the
  test directory
- Makes it easier for developers to find relevant tests

This is a pure organizational change with no logic modifications.

* fix(tests): resolve Python 3.10 mock.patch compatibility issue

Updates three banner tests to patch TerminalColors.NO_COLOR at the
import location (nac_test.cli.ui.banners) rather than the definition
location (nac_test.utils.terminal).

Python 3.10's mock.patch implementation has issues resolving nested
class attributes when patching at the definition location, causing:
  ModuleNotFoundError: No module named 'nac_test.utils.terminal.TerminalColors'

Python 3.11+ handles this correctly, but for 3.10 compatibility,
patching at the import location is the standard best practice.

Fixes CI failures in Tests (3.10):
- TestDisplayAciDefaultsBanner::test_respects_no_color_mode
- TestDisplayAuthFailureBanner::test_respects_no_color_mode
- TestDisplayUnreachableBanner::test_respects_no_color_mode

* fix(auth): protect sys.argv during pyats-common lazy imports

PyATS's configuration module parses sys.argv at import time,
registering --pyats-configuration as a known argument. When
_get_auth_callable() lazily imports from nac_test_pyats_common,
the parent package __init__.py eagerly imports all test base
classes, which triggers `from pyats import aetest`, initializing
the configuration module. Python argparse prefix-matches our
--pyats CLI flag to --pyats-configuration, causing:

  error: argument --pyats-configuration: expected one argument

Fix: temporarily strip --pyats from sys.argv during the import
and restore it in a finally block. This follows the established
pattern from device_inventory.py (lines 94-100).

* Add cache invalidation method to AuthCache

Implement AuthCache.invalidate() to support credential change detection
in preflight authentication checks. This method safely removes cached
tokens under file lock, enabling fresh authentication when credentials
are updated between test runs.

Benefits:
- Prevents stale token reuse when credentials change
- Process-safe with FileLock for parallel test execution
- Best-effort design never blocks test execution
- Automatic cleanup of both cache and lock files

The invalidation mechanism is designed for the common workflow where
users test with wrong credentials (cache miss → auth fails), then fix
credentials (invalidate → cache miss → auth succeeds), then test again
with different wrong credentials (invalidate → cache miss → auth fails).
Without this, the third scenario would incorrectly reuse the cached
token from the second run.

* Add cache_key field to ControllerConfig

Introduce cache_key mapping to centralize the translation between
detected controller types and their AuthCache storage keys. This
resolves the naming inconsistency where SDWAN detection returns
"SDWAN" but the adapter uses "SDWAN_MANAGER" as the cache key.

Benefits:
- Single source of truth for cache key mapping
- Eliminates hardcoded key strings in multiple locations
- Supports all three controller types (ACI, SDWAN, CC)
- Enables preflight check to invalidate the correct cache file

The mapping preserves existing cache behavior while making it
explicit and maintainable. Cache keys match the actual keys used
by authentication adapters in nac-test-pyats-common.

* Invalidate auth cache before preflight credential check

Integrate cache invalidation into the preflight authentication flow
to ensure fresh credential validation on every test run. This fixes
the issue where changing credentials between runs would incorrectly
reuse cached tokens from previous successful authentications.

Benefits:
- Detects credential changes immediately (no stale token reuse)
- Validates current env var credentials, not cached credentials
- Works across all controller types (ACI, SDWAN, CC)
- Best-effort design never blocks execution if invalidation fails

The fix enables the critical workflow:
  Run 1: Wrong creds → Preflight fails, banner shown, exit
  Run 2: Correct creds → Preflight succeeds, tests run, cache written
  Run 3: Wrong creds again → Cache invalidated, preflight fails, banner

Without this fix, Run 3 would incorrectly succeed using the cached
token from Run 2, allowing tests to execute with invalid credentials
in the environment.

* Add unit tests for AuthCache.invalidate()

Implement comprehensive unit tests covering the cache invalidation
method's behavior across success, no-op, and failure scenarios.

Test coverage:
- Successful cache file removal with lock acquisition
- No-op behavior when cache file doesn't exist
- Lock file cleanup after cache removal
- Graceful handling of permission errors (best-effort)

These tests validate the core invalidation logic that enables
preflight credential change detection while ensuring failures
never block test execution.

* Add unit tests for preflight cache invalidation integration

Implement unit tests validating the preflight authentication check's
integration with cache invalidation across all controller types.

Test coverage:
- ACI, SDWAN, and CC cache invalidation during preflight
- Correct cache_key used for each controller type
- No invalidation attempted for unsupported controller types
- Invalidation failures do not prevent authentication attempts

These tests ensure the preflight check correctly invalidates cached
tokens before validating current credentials, enabling immediate
detection of credential changes across all supported architectures.

* Add integration tests for preflight controller authentication

Add pytest-httpserver-based integration tests that exercise the full
auth chain: preflight_auth_check → auth adapter → subprocess → HTTP
request → error classification. Tests cover all three controller types
(APIC, SDWAN, Catalyst Center) with success and failure paths.

Test coverage:
- APIC: success (200), bad credentials (401), forbidden (403), unreachable
- SDWAN: success with session cookie + XSRF token, bad credentials (401)
- CC: success with Basic Auth, bad credentials (401 on both endpoints)

Also adds pytest-httpserver to dev dependencies and registers the
'slow' pytest marker for tests with real network timeouts.

* fix(cli): clean up preflight auth banner output

Remove verbose error detail lines from auth failure and unreachable
banners — the summary line ("Could not authenticate/connect to X at Y")
is sufficient for user-facing output, and the raw error strings were
overflowing the box borders.

Downgrade subprocess_auth and controller_auth log messages from
ERROR/WARNING to DEBUG — the banner already handles user-facing
output, so the log lines were redundant noise. Also remove the
double "Authentication failed:" prefix wrapping in SubprocessAuthError.

* fix: replace hardcoded "APIC recovery" with "controller recovery" in retry logs

The retry backoff messages referenced "APIC recovery" which is
incorrect for SD-WAN and Catalyst Center controllers. Changed to
the controller-agnostic "controller recovery" so the messages are
accurate regardless of which architecture is being tested.

* feat(types): add PreFlightFailure dataclass and ControllerTypeKey alias

Introduce frozen dataclass for pre-flight failure metadata with
Literal-typed failure_type field, and a ControllerTypeKey type alias
for consistent controller type handling across the codebase.

* refactor(constants): consolidate HTTP constants into core/constants.py

Move HTTP status code ranges, auth failure codes, and service
unavailable codes from the standalone http_constants module into
the shared constants module. Update all import sites.

* feat(utils): add shared duration and timestamp formatting utilities

Add format_duration() for smart human-readable duration display
(<1s, seconds, minutes, hours) and format_timestamp_ms() for
millisecond-precision timestamps. Single source of truth for
formatting logic used across CLI, progress, and reporting.

* test(utils): add unit tests for format_duration and format_timestamp_ms

Cover all formatting tiers: None/N/A, sub-second, seconds, minutes,
hours for duration; millisecond precision and default-to-now for
timestamps. 14 test cases total.

* refactor(base-test): use timestamp format constants instead of inline strings

Replace hardcoded strftime format strings with FILE_TIMESTAMP_FORMAT
and FILE_TIMESTAMP_MS_FORMAT from core constants.

* refactor(subprocess-runner): use FILE_TIMESTAMP_MS_FORMAT constant

Replace inline strftime format string with shared constant.

* refactor(archive-aggregator): use FILE_TIMESTAMP_MS_FORMAT constant

Replace inline strftime format string with shared constant.

* refactor(batching-reporter): use FILE_TIMESTAMP_FORMAT constant

Replace inline strftime format string with shared constant.

* refactor(progress-reporter): use shared format_timestamp_ms utility

Replace inline timestamp formatting with the shared utility function
for consistent millisecond-precision timestamps.

* refactor(templates): delegate to shared formatting utilities

Replace inline timestamp and duration formatting with calls to the
shared format_timestamp_ms and format_duration utilities.

* refactor(robot-orchestrator): use shared time format and duration utility

Replace inline strftime format string with CONSOLE_TIME_FORMAT constant
and verbose duration formatting with format_duration().

* refactor(robot-parser): use ROBOT_TIMESTAMP_FORMAT constants

Replace inline Robot Framework timestamp format strings with
ROBOT_TIMESTAMP_FORMAT and ROBOT_TIMESTAMP_FORMAT_NO_MS constants.

* refactor(robot-generator): use REPORT_TIMESTAMP_FORMAT constant

Replace inline strftime format string with shared constant.

* refactor(pyats-orchestrator): use shared format_duration utility

Replace 8-line inline duration formatting block with single call
to the shared format_duration() function.

* refactor(summary-printer): replace duplicate format_duration with shared utility

Delete the 16-line SummaryPrinter.format_duration() static method and
import the shared format_duration from utils.formatting instead.
Updates all 4 call sites from self.format_duration() to format_duration().

* refactor(pyats-generator): use REPORT_TIMESTAMP_FORMAT constant

Replace inline strftime format string with shared constant.

* feat(combined-generator): add pre-flight failure report generation

Add CurlTemplate NamedTuple with data-driven lookup replacing if/elif
chain for curl example generation. Add _generate_pre_flight_failure_report()
to route auth and unreachable failures through the unified combined
summary report with 401/403 template branching.

* feat(cli): route auth failures through unified reporting pipeline

Create PreFlightFailure on auth/unreachable and pass to
CombinedReportGenerator instead of standalone auth_failure module.
Replace inline duration formatting with format_duration() call.

* refactor(cli): delete standalone auth_failure reporting module

Auth failure report generation now handled by CombinedReportGenerator.
The standalone cli/reporting/ module is no longer needed.

* test(cli): delete obsolete auth_failure report tests

Test coverage for pre-flight failure reports now lives in
test_combined_generator.py alongside the unified reporting pipeline.

* test(utils): relocate URL extraction tests to tests/unit/utils/

Move extract_host tests from the deleted cli/reporting test module
to tests/unit/utils/test_url.py alongside the utility they test.

* test(combined-generator): add curl template and pre-flight failure tests

Add TestGetCurlExample (4 tests) for data-driven curl template
generation, TestPreFlightFailureReport (6 tests) for auth/unreachable
failure report generation including 401/403 branching and negative
assertion for legacy auth_failure_report.html.

* test(cli): update auth test assertions for unified reporting pipeline

Adjust test mocking and assertions to match the new PreFlightFailure
routing through CombinedReportGenerator instead of standalone module.

* chore: update uv.lock

* fix(reporting): remove redundant f-string wrapper on plain return value

_get_curl_example() wrapped a bare variable in an f-string
(`return f"{controller_url}"`) when no interpolation was needed.
This is flagged by ruff as an unnecessary f-string (F541-adjacent
pattern) and adds runtime overhead for the string copy. Replace with
a direct `return controller_url`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(reporting): restore "Forbidden" text check in is_403 detection

The is_403 flag in _generate_pre_flight_failure_report() only checked
for the numeric "403" string via HTTP_FORBIDDEN_CODE. Some controller
responses include the word "Forbidden" without the numeric code (e.g.,
"Forbidden: insufficient privileges"). The original auth_failure.py
module checked both patterns, but the "Forbidden" text match was lost
during the migration to the unified reporting pipeline.

Restore the disjunction so the template receives is_403=True for both
"403" and "Forbidden" responses, ensuring the HTML report shows the
correct troubleshooting guidance for permission-denied errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(reporting): add explicit encoding="utf-8" to normal-path write_text

The pre-flight failure code path already specified encoding="utf-8"
when writing combined_summary.html, but the normal (success) code
path relied on the platform default. On systems where the default
encoding is not UTF-8 this would produce garbled HTML output.

Make both code paths consistent by specifying encoding="utf-8"
explicitly, matching the pre-flight failure path and the project
convention of always declaring encoding on file I/O.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(reporting): convert f-string logger calls to %-style lazy formatting

Replace all 6 f-string logger calls in CombinedReportGenerator with
%-style formatting (e.g., `logger.info("msg: %s", val)`).

f-strings are evaluated eagerly at the call site regardless of whether
the log level is enabled. %-style defers interpolation to the logging
framework, which skips the string formatting entirely when the message
would be discarded. This is the pattern recommended by the Python
logging documentation and by ruff's LOG002/G004 rule.

Affected calls:
  - generate_combined_summary(): 3 info + 1 error
  - _generate_pre_flight_failure_report(): 1 info + 1 error

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(types): remove has_pre_flight_failure property, use direct None check

Remove the `has_pre_flight_failure` property from CombinedResults and
replace its single call site in CombinedReportGenerator with a direct
`results.pre_flight_failure is not None` check.

Why: mypy cannot narrow the type of an attribute through a property
accessor. The old code required a secondary `if failure is None` guard
(marked "unreachable") just to satisfy mypy after calling
`results.has_pre_flight_failure`. A direct `is not None` check lets
mypy narrow `results.pre_flight_failure` from `PreFlightFailure | None`
to `PreFlightFailure` automatically, eliminating the dead-code guard
entirely.

This also removes one level of indirection — the property was a
single-expression wrapper around `is not None`, adding API surface
without adding clarity. The direct check is idiomatic Python and
immediately communicates what is being tested.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(auth): narrow controller_type from str to ControllerTypeKey Literal

Tighten the type of `AuthCheckResult.controller_type` and the
`preflight_auth_check()` parameter from bare `str` to the
`ControllerTypeKey` Literal alias ("ACI" | "SDWAN" | "CC" | ...).

This eliminates a class of bugs where an arbitrary string could flow
through the auth pipeline unchecked. The `ControllerTypeKey` Literal
constrains valid values at the type level, giving mypy the ability to
catch invalid controller types at analysis time rather than at runtime.

The `cast()` call is placed at the boundary in main.py where
`detect_controller_type()` (which returns `str` because it reads from
environment variables) enters the typed domain. Once cast at this
single entry point, all downstream code — `preflight_auth_check()`,
`AuthCheckResult`, `PreFlightFailure` — receives a properly typed
`ControllerTypeKey` without needing per-site casts.

This removes the redundant `cast(ControllerTypeKey, ...)` that was
previously needed when constructing PreFlightFailure from
auth_result.controller_type, since that field is now already typed
as ControllerTypeKey.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(formatting): restore .2f precision in format_duration

The previous commit inadvertently changed format_duration from .2f to
.1f precision, dropping the second decimal place (e.g., "3.25s" became
"3.3s"). This restores the original .2f format specifier so durations
display with two decimal places, which is more useful for distinguishing
test timings at sub-second granularity.

The docstring examples are updated to reflect the correct output format.

* test(formatting): update format_duration assertions for .2f precision

Updates three test expectations to match the restored .2f format
specifier in format_duration:
  - "1.0s"  -> "1.00s"
  - "45.2s" -> "45.20s"
  - "30.0s" -> "30.00s"

* feat(formatting): add format_file_timestamp_ms() utility function

Three call sites (subprocess_runner, archive_aggregator, base_test)
were each independently doing:

    datetime.now().strftime(FILE_TIMESTAMP_MS_FORMAT)[:-3]

This is a leaky abstraction — callers had to know about the [:-3]
slice that trims microseconds (6 digits) to milliseconds (3 digits),
and each had to import both datetime and the format constant.

The new format_file_timestamp_ms() function encapsulates this pattern
in a single place, consistent with the existing format_timestamp_ms()
function that already handles the display-oriented timestamp format.

The _MICROSECOND_TRIM constant is reused by both functions.

* test(formatting): add tests for format_file_timestamp_ms

Adds TestFormatFileTimestampMs class with four tests covering:
- Known datetime produces exact expected string ("20250627_182616_834")
- Microsecond-to-millisecond trimming works correctly (123456 -> "123")
- None argument defaults to current time (boundary check)
- Output format has the expected 19-character length

These mirror the existing TestFormatTimestampMs structure to maintain
consistency in how we test the two timestamp formatting functions.

* refactor(subprocess-runner): use format_file_timestamp_ms utility

Replaces the inline datetime.now().strftime(FILE_TIMESTAMP_MS_FORMAT)[:-3]
pattern with the new format_file_timestamp_ms() utility. This removes the
need for subprocess_runner to know about the microsecond trimming detail
and drops the now-unused datetime and FILE_TIMESTAMP_MS_FORMAT imports.

* refactor(archive-aggregator): use format_file_timestamp_ms utility

Replaces the inline datetime.now().strftime(FILE_TIMESTAMP_MS_FORMAT)[:-3]
pattern with the new format_file_timestamp_ms() utility. Drops the
now-unused datetime and FILE_TIMESTAMP_MS_FORMAT imports.

This is the second of three call sites migrated to the shared utility.

* refactor(base-test): use format_file_timestamp_ms utility

Replaces the inline strftime + [:-3] slice with format_file_timestamp_ms()
in _generate_test_id(). Removes the now-unused FILE_TIMESTAMP_MS_FORMAT
import from core.constants (datetime import is retained — it is still
used in two other places in this module).

This is the last of three call sites migrated to the shared utility,
completing the elimination of the leaky [:-3] abstraction.

* feat(error-classification): add extract_http_status_code() function

Adds a public function to extract the HTTP status code from an
exception message as an integer, returning None if no code is found.

This reuses the existing _HTTP_STATUS_CODE_PATTERN regex that
_classify_auth_error already uses internally, so the extraction logic
is consistent. The function is intentionally separate from
_classify_auth_error (rather than changing it to return a 3-tuple)
to avoid breaking the 11 existing call sites and their test assertions.

This will be used by the pre-flight auth check to propagate structured
status codes into AuthCheckResult and PreFlightFailure, enabling
reliable is_403 detection without fragile substring matching.

* feat(types): add status_code field to PreFlightFailure

Adds an optional status_code: int | None field to the PreFlightFailure
frozen dataclass. This carries the structured HTTP status code from the
auth check through to the reporting layer, enabling reliable detection
of specific HTTP conditions (e.g., 403 Forbidden) without resorting to
fragile substring matching on the detail string.

The field defaults to None, which is correct for non-HTTP failures like
connection timeouts or DNS resolution errors where there is no HTTP
status code to report.

* feat(auth): add status_code field to AuthCheckResult

Mirrors the status_code field just added to PreFlightFailure. This
allows the pre-flight auth check to capture the HTTP status code at
the point of failure and pass it through to the reporting layer via
PreFlightFailure.

The field defaults to None, which is correct for both successful auth
checks (no error) and non-HTTP failures (timeouts, DNS, etc.).

* feat(auth): extract and propagate HTTP status_code in preflight check

Imports extract_http_status_code and calls it in the exception handler
of preflight_auth_check() to capture the HTTP status code from the
failed auth request. The extracted code is passed into AuthCheckResult
via the new status_code field.

This is the wiring that connects extract_http_status_code (added to
error_classification.py) with the AuthCheckResult field (added in the
previous commit). For non-HTTP errors (timeouts, DNS, etc.),
extract_http_status_code returns None, which is the correct default.

* fix(reporting): replace is_403 substring matching with structured check

The previous is_403 detection used fragile substring matching:

    is_403 = (
        str(HTTP_FORBIDDEN_CODE) in failure.detail
        or "Forbidden" in failure.detail
    )

This could false-positive on any detail string containing "403" or
"Forbidden" as a substring (e.g., "tried 403 times" or a URL path
containing "Forbidden"). It also required the detail string format
to remain stable.

Now that PreFlightFailure carries a structured status_code field,
we can use a direct integer comparison instead:

    is_403 = failure.status_code == HTTP_FORBIDDEN_CODE

This is unambiguous, type-safe, and decoupled from the detail string.

* feat(cli): pass status_code from AuthCheckResult to PreFlightFailure

Threads the status_code field through the CLI main function where
AuthCheckResult is converted into PreFlightFailure. This completes the
data flow from the pre-flight auth check through to the report
generator, where it replaces the fragile substring-based is_403 check.

* test(auth): add status_code propagation tests for preflight check

Adds two tests to TestPreflightAuthCheck:

1. test_propagates_http_status_code — verifies that when auth fails
   with "HTTP 403: Forbidden", the result carries status_code=403.
   This proves the extract_http_status_code -> AuthCheckResult wiring
   works end-to-end.

2. test_status_code_none_for_non_http_errors — verifies that when auth
   fails with a non-HTTP error like "Connection timed out", the
   status_code is None (not some spurious number extracted from the
   error message).

Also adds type: ignore[arg-type] to test_handles_unknown_controller_type
which deliberately passes an invalid controller type string to verify
graceful handling. The ignore is needed because preflight_auth_check now
expects ControllerTypeKey (a Literal type), and "UNKNOWN_CONTROLLER" is
intentionally outside that set — that's the entire point of the test.

* test(error-classification): add tests for extract_http_status_code

Adds TestExtractHttpStatusCode class with five tests covering:
- 401 extraction from "HTTP 401: Unauthorized"
- 403 extraction from "HTTP 403: Forbidden"
- 500 extraction from "HTTP 500: Internal Server Error"
- None returned for non-HTTP messages ("Connection timed out")
- None returned for generic messages ("Something went wrong")

These validate the public extract_http_status_code() function that was
added to error_classification.py, ensuring it correctly reuses the
_HTTP_STATUS_CODE_PATTERN regex to extract status codes and returns
None when no HTTP status code is present.

* test(combined-generator): add status_code to PreFlightFailure fixtures

Updates five PreFlightFailure constructors in the combined generator
tests to include the new status_code field, matching the structured
data that the production code now provides:

- Four auth failure tests: status_code=401 (matching "HTTP 401" detail)
- One 403 test: status_code=403 (matching "HTTP 403: Forbidden" detail)
- Unreachable test: unchanged (no status_code, defaults to None)

This ensures the test fixtures accurately reflect the data shape that
flows through the reporting pipeline in production, where the is_403
check now uses failure.status_code == HTTP_FORBIDDEN_CODE rather than
substring matching on the detail string.

* refactor(reporting): narrow _CURL_TEMPLATES key type to ControllerTypeKey

Changes the _CURL_TEMPLATES dict from dict[str, _CurlTemplate] to
dict[ControllerTypeKey, _CurlTemplate], and narrows the controller_type
parameter of _get_curl_example from str to ControllerTypeKey.

Since _CURL_TEMPLATES keys are always valid controller type literals
("ACI", "SDWAN", "CC"), the type annotation should reflect this.
Using ControllerTypeKey catches typos at type-check time and makes the
data flow consistent with the rest of the reporting pipeline, where
PreFlightFailure.controller_type is already ControllerTypeKey.

* refactor(controller): narrow detect_controller_type return to ControllerTypeKey

Changes the return type of detect_controller_type() from str to
ControllerTypeKey, which is a Literal type covering all supported
controller identifiers ("ACI", "SDWAN", "CC", "MERAKI", etc.).

This eliminates the need for callers to cast() the return value,
since the function now guarantees at the type level that it returns
a valid controller type key.

A cast(ControllerTypeKey, ...) is needed at the return point because
complete_sets is typed as list[str] (populated from CONTROLLER_REGISTRY
dict iteration). The keys are always valid ControllerTypeKey values,
but mypy can't infer this from dict key iteration. The cast documents
this boundary explicitly.

* refactor(cli): remove cast() now that detect_controller_type returns ControllerTypeKey

Now that detect_controller_type() declares its return type as
ControllerTypeKey (the previous commit), the cast() wrapper in main()
is redundant. Removing it also drops the now-unused cast and
ControllerTypeKey imports.

Before: controller_type = cast(ControllerTypeKey, detect_controller_type())
After:  controller_type = detect_controller_type()

The type narrowing is now handled at the source (controller.py) rather
than at every call site — one cast instead of N.

* test(auth): add type: ignore for parametrized controller_type argument

The pytest @parametrize decorator infers controller_type as str, but
preflight_auth_check now expects ControllerTypeKey (a Literal type).
The values ("ACI", "SDWAN", "CC") are all valid ControllerTypeKey
members, but mypy cannot narrow str to Literal through parametrize.

Adding type: ignore[arg-type] is the standard approach for this
pytest + Literal type interaction. The runtime behavior is unchanged.

* feat(banners): add _wrap_url_lines helper for banner content wrapping

Long controller URLs (e.g., https://sandboxdnacultramdeupURL.cisco.com)
can exceed the fixed 78-character banner content width, causing text to
overflow past the right border and breaking the visual box.

This adds a _wrap_url_lines() helper that keeps the URL on the same line
when the combined prefix + URL fits within BANNER_CONTENT_WIDTH, and
wraps the URL to an indented second line when it would overflow. This
approach preserves the fixed-width box (which is important for
consistent terminal rendering across 80-column terminals) while ensuring
all content stays within the borders.

The helper accepts an optional indent parameter for lines that are
already indented (e.g., "  curl -k <url>") so that both the prefix
and wrapped URL line receive consistent leading whitespace.

* fix(banners): wrap long URLs in auth failure banner

The "Could not authenticate to {display_name} at {url}" line is
constructed at runtime, and with long controller URLs (common in
enterprise environments like Catalyst Center sandboxes), the combined
string exceeds the 78-character banner width, causing the right border
to shift out of alignment.

This replaces the single f-string with a _wrap_url_lines() call that
keeps the URL inline for short URLs and wraps it to a second indented
line for long ones. Short URLs (e.g., https://apic.local) render
identically to before — no visual change for the common case.

* fix(banners): wrap long URLs in unreachable banner

The unreachable banner has two lines that embed the controller URL:
the "Could not connect to..." message and the "curl -k <url>" command.
Both can overflow the 78-character banner width with long URLs.

This applies _wrap_url_lines() to both lines. The curl command uses the
indent="  " parameter so both the "curl -k" prefix and the wrapped URL
receive the 2-space indent that visually groups them under the
"Verify the controller is reachable..." instruction line.

The ping command is not wrapped because it uses the extracted hostname
(not the full URL), which is always short enough to fit.

* test(banners): add unit tests for _wrap_url_lines helper

Tests the four key behaviors of the URL wrapping helper:

1. Short URLs that fit within BANNER_CONTENT_WIDTH stay on one line,
   preserving the original single-line format for the common case.

2. Long URLs that exceed the width are wrapped to a second line with
   2-space indentation, keeping the prefix on its own line.

3. The indent parameter is applied to both lines when wrapping occurs,
   so indented commands like "  curl -k" produce "  curl -k" / "    <url>"
   with the URL indented 2 more spaces relative to the prefix.

4. The indent parameter is applied to the single line when no wrapping
   is needed, maintaining consistent indentation.

Each test explicitly verifies its precondition (e.g., that the test URL
actually exceeds the width) to prevent tests from silently passing if
BANNER_CONTENT_WIDTH changes in the future.

* test(banners): add end-to-end tests for long URL banner rendering

These tests verify the actual rendered banner output — not just the
helper function — to ensure no line overflows the box border when a
long controller URL is used. This catches regressions that unit tests
on _wrap_url_lines alone would miss, such as a new banner line being
added that embeds the URL without using the wrapping helper.

Each test uses the same long URL from the original bug report
(https://sandboxdnacultramdeupURL.cisco.com) and asserts that every
rendered line is at most BANNER_CONTENT_WIDTH + 2 characters (the
content width plus the two border characters).

Both display_unreachable_banner and display_auth_failure_banner are
covered since they have different content layouts but the same overflow
risk.

* fix(cli): skip controller detection and preflight auth in dry-run mode

Dry-run mode doesn't need controller access, same as render-only.
Gate both detect_controller_type() and preflight_auth_check() behind
`not render_only and not dry_run` in main() and CombinedOrchestrator.

* fix(tests): mock preflight auth check in integration tests

The integration tests use bogus ACI credentials (ACI_URL=foo) which now
trigger the preflight auth check added in the previous commit. Since
these tests validate Robot rendering/execution/ordering behavior (not
authentication), mock both detect_controller_type and preflight_auth_check
in the existing setup_bogus_controller_env autouse fixtures.

* fix(tests): fix auth cache lock file test for filelock >= 3.13

filelock 3.24.2 (UnixFileLock) auto-deletes .lock files on release,
so the lock file does not persist between separate FileLock acquisitions.
The test incorrectly asserted .lock file existence after _cache_auth_data()
returned — this was testing filelock library behavior, not our code.

Updated to verify the end state: no cache or lock artifacts remain after
invalidation, regardless of filelock's cleanup behavior.

* refactor: move preflight auth check from CLI to CombinedOrchestrator

The nac-test CLI should remain a generic testing engine (Jinja + pabot)
that isn't tied to infrastructure controllers. This moves controller
detection and preflight auth validation from main.py into
CombinedOrchestrator.run_tests(), gated by has_pyats and not
render_only/dry_run.

Changes:
- Remove controller detection and auth check block from cli/main.py
- Add preflight auth check to CombinedOrchestrator.run_tests()
- Consolidate controller detection into run_tests() flow
- Add shared integration test conftest with setup_bogus_controller_env
- Use pytest.mark.usefixtures for selective fixture application
- Remove redundant auth mocks from unit tests (CombinedOrchestrator
  is fully mocked via @patch)
- Update test assertions for new orchestrator-level auth handling

* fix: skip preflight auth check in dry-run mode

Dry-run mode validates test structure, not execution — it should not
require controller credentials. The auth gate in run_tests() now
checks `not self.dry_run` alongside `not self.render_only`.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
oboehmer pushed a commit that referenced this pull request Mar 19, 2026
* Add shared architecture detection helper for validators

Introduces is_architecture_active() helper function that provides a
lightweight check for whether an architecture environment is active by
testing if its URL environment variable is set.

This helper is shared across all architecture-specific validators (ACI,
SD-WAN, Catalyst Center) to avoid duplicating environment detection logic.

Rationale: Validators need to know which architecture is active to decide
whether to perform validation. Centralizing this check in common.py ensures
consistency and makes adding new architecture validators straightforward.

* Add ACI defaults validation with two-stage heuristic

Implements validate_aci_defaults() that fails fast when users forget to
pass the ACI defaults file, catching the common mistake of omitting
-d ./defaults/ before expensive data merge operations.

Two-stage validation approach:

Stage 1 (Instant path-based heuristic):
- Checks if path contains "default" or "defaults" as directory component
- For files: requires .yaml/.yml extension + "default" in filename
- Rejects false positives like "/users/defaultuser/" or "config.txt"

Stage 2 (Content-based fallback):
- Parses YAML files to find "defaults.apic" structure
- Non-recursive directory scanning for performance
- Handles both .yaml and .yml extensions

Security features:
- Rejects symlinks to prevent path traversal
- Limits file scanning to 3MB to prevent memory exhaustion

Performance: Path heuristic provides instant feedback for 95% of cases.
Content scanning only runs when path-based check doesn't match, adding
minimal overhead (~50-100ms) compared to time saved when defaults are
missing (users get immediate feedback instead of waiting for full merge).

* Add validators package public API exports

Exports validate_aci_defaults() and is_architecture_active() as the
public API for the validators package.

Enforces separation of concerns: validators package only exports
validation functions, not UI components. Display functions like
display_aci_defaults_banner() should be imported directly from
nac_test.cli.ui, maintaining clear architectural boundaries between
validation logic and presentation.

This design allows adding new architecture validators (SD-WAN, Catalyst
Center) by following the same pattern without coupling to UI concerns.

* Add terminal banner display for ACI defaults error

Implements display_aci_defaults_banner() to show a prominent, user-friendly
error message when ACI defaults file is missing.

BoxStyle dataclass design:
- Encapsulates box-drawing characters for terminal rendering
- Frozen dataclass ensures immutability
- Supports both ASCII (fallback) and Unicode (modern terminals)
- Includes emoji_adjustment field to handle Unicode width quirks

NO_COLOR environment variable support:
- Respects NO_COLOR standard for accessibility
- Falls back to plain ASCII when NO_COLOR is set or Unicode unavailable

Banner content includes:
- Clear error message explaining the requirement
- Working example command showing correct syntax
- Link to Cisco ACI documentation for context
- Professional formatting with borders and sections

Rationale: Validation errors need to be immediately visible and actionable.
A prominent banner with example command helps users fix the issue quickly
instead of hunting through error logs.

* Add UI package public API for banner display

Exports display_aci_defaults_banner() as the public interface for CLI
error banners. Future banner functions (SD-WAN, Catalyst Center) will
be exported from this package.

Architectural separation: UI package handles all user-facing display
logic, while validators package handles business logic. This separation
allows testing and modifying display behavior independently from
validation rules.

* Wire ACI defaults validation into CLI entry point

Adds validation check immediately before DataMerger.merge_data_files()
to fail fast when defaults are missing. Validation runs after output
directory creation but before expensive merge operation.

Execution flow:
1. Parse CLI arguments
2. Configure logging
3. Create output directory
4. → NEW: Validate ACI defaults (instant check)
5. Merge data files (expensive operation, 2-5 seconds)
6. Continue with orchestrator...

Error handling:
- Validation failure displays prominent banner via display_aci_defaults_banner()
- Exits with code 1 (failure) to prevent continuing with invalid config
- Blank lines before/after banner improve readability

Time savings: Users get immediate feedback instead of waiting 2-5 seconds
for data merge to complete before seeing validation error. Pre-flight check
philosophy: validate prerequisites before expensive operations.

* Add test directory structure for CLI components

Creates package structure mirroring source code organization:
- tests/unit/cli/validators/ for validator unit tests
- tests/unit/cli/ui/ for UI component unit tests

Empty __init__.py files make directories importable Python packages,
allowing pytest to discover tests and enabling relative imports within
test modules.

Test organization principle: Test structure should mirror source
structure. This makes finding tests for a given module intuitive and
maintains clear architectural boundaries in both source and test code.

* Add comprehensive unit tests for ACI defaults validator

Tests validate_aci_defaults() and helper functions with 32 test cases
covering environment detection, path matching, YAML parsing, security
features, and edge cases.

Test coverage includes:

Environment detection (3 tests):
- Validation skips when ACI_URL not set
- Validation runs when ACI_URL is set
- Empty string ACI_URL treated as not set

Path-based heuristic (9 tests):
- Matches "defaults" directory component
- Matches defaults.yaml and defaults.yml files
- Rejects non-YAML files (defaults.txt, defaults.json)
- Rejects substring matches (defaultuser, my-defaulted-config)
- Case-insensitive matching for directory names

YAML content validation (8 tests):
- Finds defaults.apic structure in YAML files
- Handles both .yaml and .yml extensions
- Rejects files missing "defaults:" or "apic:" keys
- Handles empty files and unreadable files gracefully

Security features (2 tests):
- Rejects symlinks to prevent path traversal
- Skips files larger than 3MB to prevent memory exhaustion

Performance features (1 test):
- Directory scanning is non-recursive (documented design decision)

Edge cases (9 tests):
- Two-stage validation (path check → content check)
- Multiple data paths with mixed content
- Content check runs as fallback when path check fails

Test quality: Uses real filesystem operations (tmp_path) not mocks,
validates actual application behavior not library functionality,
includes comprehensive docstrings documenting test intent.

* Add unit tests for ACI defaults banner display

Tests display_aci_defaults_banner() function with focus on output
content, NO_COLOR environment variable support, and error handling.

Test coverage includes:

Banner content validation (3 tests):
- Banner displays without raising exceptions
- Banner contains required error message text
- Banner includes example command with correct syntax

NO_COLOR support (2 tests):
- ASCII box style used when NO_COLOR environment variable is set
- Unicode box style used when NO_COLOR is not set

Test approach: Uses pytest's capsys fixture to capture stdout and
validate actual terminal output. Tests verify real banner display
behavior, not mocked components.

Note: Banner tests focus on content validation (error message, example
command) rather than exact formatting (border characters, spacing).
This design allows banner styling to evolve without breaking tests as
long as essential content remains present.

* Add integration tests for CLI ACI defaults validation

Implements 6 integration tests split into two test classes validating
end-to-end CLI behavior with ACI defaults validation.

TestCliAciValidationIntegration (4 tests using CliRunner):
- Tests validation triggers when ACI_URL set without defaults
- Tests validation skips when ACI_URL not set
- Tests validation passes when defaults directory provided
- Tests validation passes when YAML contains defaults.apic structure

TestCliAciValidationSubprocess (2 tests using subprocess.run):
- Tests actual nac-test CLI process execution with ACI_URL
- Tests CLI subprocess without ACI_URL environment variable
- Marked with skipif when CLI not installed in PATH

Integration test design:

CliRunner tests (lines 26-195):
- Use Typer's CliRunner for fast integration testing
- Mock expensive operations (DataMerger, CombinedOrchestrator)
- Validate CLI wiring: arguments → validators → UI → exit codes
- Test focus: Does validation logic integrate correctly with CLI?

Subprocess tests (lines 197-301):
- Execute real nac-test command in subprocess
- Zero mocking - tests production behavior
- Validate complete process: entry point → imports → execution → exit
- Test focus: Does CLI actually work end-to-end in production?

Test fixtures:
- clean_controller_env: Clears environment variables for isolation
- minimal_test_env: Creates temporary directories and YAML files
- cli_test_env: Subprocess-specific environment setup

Coverage: Tests validate that validation runs at correct point in CLI
flow (after argument parsing, before data merge), displays banner on
failure, returns correct exit codes, and handles environment variables.

* Fix CI: Add defaults.yaml to integration test fixtures

Integration tests in test_integration_robot_pabot.py set ACI_URL
environment variable via setup_bogus_controller_env fixture, which
triggers the new ACI defaults validation.

Adding defaults.yaml to test fixtures satisfies the validation check
without modifying test logic or environment setup. The file contains
minimal defaults.apic structure required by the validator.

This fixes 20 failing integration tests that were exiting with code 1
(validation failure) instead of code 0 (success).

* Fix CI: Add defaults.yaml to all integration test fixture directories

Additional fixture directories (data_env, data_list, data_list_chunked,
data_merge) also used by integration tests need defaults.yaml to satisfy
ACI validation when ACI_URL environment variable is set.

Ensures all 20 failing integration tests pass by providing the minimal
defaults.apic structure required by the validator in all test fixture
data directories.

* Fix CI: Add defaults.apic structure to data_merge test fixtures

The test_nac_test_render_output_model test passes individual YAML files
(file1.yaml, file2.yaml) via -d flags instead of a directory. Our
validator performs content-based checking on these files.

Adding defaults.apic structure to file1.yaml satisfies the validator's
content check. The expected result.yaml is updated to include the
defaults structure since it's part of the merged data model.

This fixes the 2 remaining failing integration tests.

* Add defaults.yaml to PyATS quicksilver test fixtures

The test_nac_test_pyats_quicksilver_api_only tests set architecture-specific
URL environment variables (ACI_URL, CC_URL, SDWAN_URL) which trigger the
ACI defaults validation. These fixture directories need minimal defaults.yaml
files with the required structure to satisfy the validation check.

Files added:
- tests/integration/fixtures/data_pyats_qs/aci/defaults.yaml
- tests/integration/fixtures/data_pyats_qs/catc/defaults.yaml
- tests/integration/fixtures/data_pyats_qs/sdwan/defaults.yaml

Each contains minimal mock structure: defaults.apic.version = 6.0

This fixes the failing CI test: test_nac_test_pyats_quicksilver_api_only[aci-ACI-1-0-0]

* fix(tests): update test imports for Phase 1 refactoring

Updated test files to use new names introduced in Phase 1:
- CONTROLLER_REGISTRY instead of separate dict constants
- extract_host() instead of _extract_host()
- Updated test class and test assertions for dataclass-based config

Added type: ignore comments for untyped Auth class methods to satisfy mypy.

All 64 tests now pass successfully.

* refactor: create centralized HTTP status constants module

Introduces core/http_constants.py as the single source of truth for HTTP
status code range boundaries and special status code sets used throughout
the framework.

Why this improves the codebase:
- Eliminates DRY violations where HTTP status ranges were defined in
  multiple locations (subprocess_client.py, http/__init__.py)
- Provides consistent status code classification logic
- Makes it easier to maintain and update HTTP-related constants
- Improves type safety with explicitly typed constants
- Centralizes authentication failure codes (401, 403) and service
  unavailable codes (408, 429, 503, 504) for reuse

This module serves as a foundation for other refactoring work that will
migrate existing code to use these centralized constants.

* refactor: migrate subprocess_client.py to centralized HTTP constants

Updates subprocess_client.py to import and use HTTP status code constants
from the new core/http_constants.py module instead of defining them locally.

Why this improves the codebase:
- Eliminates duplication of HTTP status range definitions
- Ensures consistent status code classification across the framework
- Makes future updates to HTTP constants affect all consumers automatically
- Improves maintainability by reducing places where constants are defined

This is part of a broader effort to establish core/http_constants.py as
the single source of truth for HTTP-related constants.

* refactor: update http/__init__.py to re-export centralized constants

Updates the http package's __init__.py to re-export HTTP constants from
core/http_constants.py rather than defining them locally. This maintains
backward compatibility for any code importing from nac_test.pyats_core.http.

Why this improves the codebase:
- Preserves existing import paths for backward compatibility
- Delegates to the single source of truth (core/http_constants.py)
- Reduces maintenance burden by having only one place to update constants
- Makes the http package a thin facade over the core constants module

This change ensures that code using either import path gets the same
constants, preventing potential inconsistencies.

* refactor: create URL parsing utility module

Introduces utils/url.py with extract_host() function to provide robust URL
parsing capabilities using Python's standard library urlparse.

Why this improves the codebase:
- Centralizes URL manipulation logic previously duplicated or ad-hoc
- Uses Python's battle-tested urlparse for robust parsing (handles edge
  cases like missing schemes, IPv6 addresses, port numbers)
- Provides consistent host extraction across the codebase
- Eliminates brittle string manipulation approaches (split('/'), regex)
- Improves testability by isolating URL parsing logic
- Makes the codebase more maintainable by having a single utility for URL operations

This utility will be used by auth_failure.py, controller_auth.py, and
potentially other modules that need to extract host information from URLs.

* refactor: create error classification module

Introduces core/error_classification.py to extract and centralize
authentication error classification logic. This module provides AuthOutcome
enum and classification functions for HTTP and network errors.

Why this improves the codebase:
- Separates error classification logic from controller authentication logic
  (Single Responsibility Principle)
- Makes error classification logic highly testable in isolation
- Uses a two-tier classification strategy: check network failures first
  (timeouts, connection refused, DNS) to avoid false positives from port
  numbers being matched as HTTP status codes
- Provides structured outcome classification via AuthOutcome enum instead
  of raw strings
- Centralizes HTTP status code interpretation logic (401/403 → bad
  credentials, 408/429/503/504 → unreachable, etc.)
- Uses regex for reliable HTTP status code extraction from error messages
- Improves maintainability by having one place to update classification rules

This module will be used by controller_auth.py and auth_failure.py to
provide consistent error categorization across the framework.

* refactor: add controller metadata helpers and structured config

Adds ControllerConfig dataclass and CONTROLLER_REGISTRY to centralize
controller metadata, along with two new helper functions:
get_display_name() and get_env_var_prefix().

Why this improves the codebase:
- Eliminates DRY violations where controller display names and env var
  prefixes were hardcoded in 5+ locations across the codebase
- Provides a single source of truth (CONTROLLER_REGISTRY) for all
  controller metadata (display names, URL env vars, credential patterns)
- Introduces ControllerConfig dataclass for type-safe controller metadata
- Makes controller information easily accessible via simple helper functions
- Improves maintainability: adding new controllers or changing display
  names now requires updates in only one place
- Preserves backward compatibility by maintaining CREDENTIAL_PATTERNS as
  a view over CONTROLLER_REGISTRY

The helper functions gracefully degrade by returning the controller_type
string if a controller is not in the registry, preventing crashes when
dealing with unknown controller types.

* refactor: fix type annotation and extract banner rendering logic

Fixes the ColorValue type annotation in banners.py (was incorrectly using
int | tuple, now correctly str | int | tuple) and extracts common banner
rendering logic into a reusable _render_banner() function.

Why this improves the codebase:
- Corrects type annotation to match typer's actual color value type (must
  include str for named colors like "red", "white")
- Eliminates massive code duplication across banner display functions by
  extracting shared rendering logic into _render_banner()
- Reduces line count by ~100+ lines while preserving all functionality
- Makes banner rendering logic testable in isolation
- Improves maintainability: changes to border rendering now affect all
  banners automatically
- Uses controller helper functions (get_display_name, extract_host) to
  eliminate hardcoded controller names
- Follows DRY principle by having a single implementation of box-drawing
  character handling and color application

All public banner functions now delegate to _render_banner() with
appropriate title, content, and color parameters.

* refactor: migrate main.py to use controller helper functions

Updates main.py to use get_display_name() and get_env_var_prefix() from
utils.controller instead of hardcoding controller names and prefixes.

Why this improves the codebase:
- Eliminates hardcoded controller display names (no more "SDWAN Manager"
  string literals scattered throughout)
- Reduces coupling between main.py and controller-specific knowledge
- Makes the code more maintainable: adding/changing controller names now
  only requires updates to CONTROLLER_REGISTRY
- Improves consistency by ensuring all code uses the same display names
- Follows DRY principle by delegating to centralized helper functions

This is part of a broader effort to eliminate controller metadata
duplication across the codebase.

* refactor: migrate auth_failure.py to use centralized utilities

Updates auth_failure.py to use get_display_name() from utils.controller
and extract_host() from utils.url instead of defining logic inline.

Why this improves the codebase:
- Eliminates hardcoded controller display names
- Replaces brittle URL host extraction (split('/')) with robust urlparse
- Reduces coupling between auth_failure.py and controller-specific logic
- Improves testability by delegating to tested utility functions
- Makes the code more maintainable and consistent with the rest of the
  codebase
- Follows DRY principle by reusing centralized utilities

This change ensures auth_failure.py uses the same display names and URL
parsing logic as the rest of the framework.

* test: update controller_auth tests for new module structure

Updates test_controller_auth.py to reflect the refactored module structure
where error classification and controller registry moved to separate modules.

Why this improves the test suite:
- Removes tests for AuthOutcome enum (now tested in test_error_classification.py)
- Removes tests for AuthCheckResult dataclass (simple dataclass, no logic)
- Removes tests for ControllerConfig (now tested in test_controller.py)
- Focuses tests on the actual validation logic in controller_auth.py
- Follows the principle of testing behavior, not implementation details
- Imports CONTROLLER_REGISTRY and AuthOutcome from their new locations
- Maintains test coverage of the actual authentication validation flow

This change aligns the test suite with the refactored module boundaries,
ensuring each module's tests focus on that module's responsibilities.

* test: update banners tests for extracted rendering logic

Updates test_banners.py to test the new _render_banner() helper function
and ensures all banner functions still work correctly after refactoring.

Why this improves the test suite:
- Tests the extracted _render_banner() function directly to verify common
  banner rendering logic
- Ensures all public banner functions still produce correct output after
  delegating to _render_banner()
- Maintains coverage of NO_COLOR environment variable handling
- Tests that controller helper functions are used correctly (get_display_name)
- Follows DRY principle in tests by verifying shared logic once rather
  than duplicating assertions across multiple banner function tests

This change ensures the refactored banner code maintains the same
behavior while having better test isolation for the extracted logic.

* test: update auth_failure tests for new utility imports

Updates test_auth_failure.py to reflect that auth_failure.py now imports
display names and URL extraction from centralized utility modules.

Why this improves the test suite:
- Aligns test imports with the refactored module structure
- Tests continue to verify that auth_failure.py uses correct display
  names and URL extraction logic
- Ensures coverage of the integration between auth_failure.py and the
  new utility functions (get_display_name, extract_host)
- Maintains test isolation while verifying correct delegation to utilities

This change ensures auth_failure.py tests remain accurate after the
refactoring to use centralized utilities.

* test: move test_cli_controller_auth.py to correct directory

Moves test_cli_controller_auth.py from tests/integration/ to tests/unit/cli/
to reflect its actual testing scope and organization.

Why this improves the test suite:
- Places test file in the correct directory structure matching the module
  it tests (cli/validators/controller_auth.py)
- Improves test discoverability: unit tests for cli modules should be
  under tests/unit/cli/
- Separates unit tests from integration tests more clearly
- Follows the convention of mirroring the source tree structure in the
  test directory
- Makes it easier for developers to find relevant tests

This is a pure organizational change with no logic modifications.

* fix(tests): resolve Python 3.10 mock.patch compatibility issue

Updates three banner tests to patch TerminalColors.NO_COLOR at the
import location (nac_test.cli.ui.banners) rather than the definition
location (nac_test.utils.terminal).

Python 3.10's mock.patch implementation has issues resolving nested
class attributes when patching at the definition location, causing:
  ModuleNotFoundError: No module named 'nac_test.utils.terminal.TerminalColors'

Python 3.11+ handles this correctly, but for 3.10 compatibility,
patching at the import location is the standard best practice.

Fixes CI failures in Tests (3.10):
- TestDisplayAciDefaultsBanner::test_respects_no_color_mode
- TestDisplayAuthFailureBanner::test_respects_no_color_mode
- TestDisplayUnreachableBanner::test_respects_no_color_mode

* fix(auth): protect sys.argv during pyats-common lazy imports

PyATS's configuration module parses sys.argv at import time,
registering --pyats-configuration as a known argument. When
_get_auth_callable() lazily imports from nac_test_pyats_common,
the parent package __init__.py eagerly imports all test base
classes, which triggers `from pyats import aetest`, initializing
the configuration module. Python argparse prefix-matches our
--pyats CLI flag to --pyats-configuration, causing:

  error: argument --pyats-configuration: expected one argument

Fix: temporarily strip --pyats from sys.argv during the import
and restore it in a finally block. This follows the established
pattern from device_inventory.py (lines 94-100).

* Add cache invalidation method to AuthCache

Implement AuthCache.invalidate() to support credential change detection
in preflight authentication checks. This method safely removes cached
tokens under file lock, enabling fresh authentication when credentials
are updated between test runs.

Benefits:
- Prevents stale token reuse when credentials change
- Process-safe with FileLock for parallel test execution
- Best-effort design never blocks test execution
- Automatic cleanup of both cache and lock files

The invalidation mechanism is designed for the common workflow where
users test with wrong credentials (cache miss → auth fails), then fix
credentials (invalidate → cache miss → auth succeeds), then test again
with different wrong credentials (invalidate → cache miss → auth fails).
Without this, the third scenario would incorrectly reuse the cached
token from the second run.

* Add cache_key field to ControllerConfig

Introduce cache_key mapping to centralize the translation between
detected controller types and their AuthCache storage keys. This
resolves the naming inconsistency where SDWAN detection returns
"SDWAN" but the adapter uses "SDWAN_MANAGER" as the cache key.

Benefits:
- Single source of truth for cache key mapping
- Eliminates hardcoded key strings in multiple locations
- Supports all three controller types (ACI, SDWAN, CC)
- Enables preflight check to invalidate the correct cache file

The mapping preserves existing cache behavior while making it
explicit and maintainable. Cache keys match the actual keys used
by authentication adapters in nac-test-pyats-common.

* Invalidate auth cache before preflight credential check

Integrate cache invalidation into the preflight authentication flow
to ensure fresh credential validation on every test run. This fixes
the issue where changing credentials between runs would incorrectly
reuse cached tokens from previous successful authentications.

Benefits:
- Detects credential changes immediately (no stale token reuse)
- Validates current env var credentials, not cached credentials
- Works across all controller types (ACI, SDWAN, CC)
- Best-effort design never blocks execution if invalidation fails

The fix enables the critical workflow:
  Run 1: Wrong creds → Preflight fails, banner shown, exit
  Run 2: Correct creds → Preflight succeeds, tests run, cache written
  Run 3: Wrong creds again → Cache invalidated, preflight fails, banner

Without this fix, Run 3 would incorrectly succeed using the cached
token from Run 2, allowing tests to execute with invalid credentials
in the environment.

* Add unit tests for AuthCache.invalidate()

Implement comprehensive unit tests covering the cache invalidation
method's behavior across success, no-op, and failure scenarios.

Test coverage:
- Successful cache file removal with lock acquisition
- No-op behavior when cache file doesn't exist
- Lock file cleanup after cache removal
- Graceful handling of permission errors (best-effort)

These tests validate the core invalidation logic that enables
preflight credential change detection while ensuring failures
never block test execution.

* Add unit tests for preflight cache invalidation integration

Implement unit tests validating the preflight authentication check's
integration with cache invalidation across all controller types.

Test coverage:
- ACI, SDWAN, and CC cache invalidation during preflight
- Correct cache_key used for each controller type
- No invalidation attempted for unsupported controller types
- Invalidation failures do not prevent authentication attempts

These tests ensure the preflight check correctly invalidates cached
tokens before validating current credentials, enabling immediate
detection of credential changes across all supported architectures.

* Add integration tests for preflight controller authentication

Add pytest-httpserver-based integration tests that exercise the full
auth chain: preflight_auth_check → auth adapter → subprocess → HTTP
request → error classification. Tests cover all three controller types
(APIC, SDWAN, Catalyst Center) with success and failure paths.

Test coverage:
- APIC: success (200), bad credentials (401), forbidden (403), unreachable
- SDWAN: success with session cookie + XSRF token, bad credentials (401)
- CC: success with Basic Auth, bad credentials (401 on both endpoints)

Also adds pytest-httpserver to dev dependencies and registers the
'slow' pytest marker for tests with real network timeouts.

* fix(cli): clean up preflight auth banner output

Remove verbose error detail lines from auth failure and unreachable
banners — the summary line ("Could not authenticate/connect to X at Y")
is sufficient for user-facing output, and the raw error strings were
overflowing the box borders.

Downgrade subprocess_auth and controller_auth log messages from
ERROR/WARNING to DEBUG — the banner already handles user-facing
output, so the log lines were redundant noise. Also remove the
double "Authentication failed:" prefix wrapping in SubprocessAuthError.

* fix: replace hardcoded "APIC recovery" with "controller recovery" in retry logs

The retry backoff messages referenced "APIC recovery" which is
incorrect for SD-WAN and Catalyst Center controllers. Changed to
the controller-agnostic "controller recovery" so the messages are
accurate regardless of which architecture is being tested.

* feat(types): add PreFlightFailure dataclass and ControllerTypeKey alias

Introduce frozen dataclass for pre-flight failure metadata with
Literal-typed failure_type field, and a ControllerTypeKey type alias
for consistent controller type handling across the codebase.

* refactor(constants): consolidate HTTP constants into core/constants.py

Move HTTP status code ranges, auth failure codes, and service
unavailable codes from the standalone http_constants module into
the shared constants module. Update all import sites.

* feat(utils): add shared duration and timestamp formatting utilities

Add format_duration() for smart human-readable duration display
(<1s, seconds, minutes, hours) and format_timestamp_ms() for
millisecond-precision timestamps. Single source of truth for
formatting logic used across CLI, progress, and reporting.

* test(utils): add unit tests for format_duration and format_timestamp_ms

Cover all formatting tiers: None/N/A, sub-second, seconds, minutes,
hours for duration; millisecond precision and default-to-now for
timestamps. 14 test cases total.

* refactor(base-test): use timestamp format constants instead of inline strings

Replace hardcoded strftime format strings with FILE_TIMESTAMP_FORMAT
and FILE_TIMESTAMP_MS_FORMAT from core constants.

* refactor(subprocess-runner): use FILE_TIMESTAMP_MS_FORMAT constant

Replace inline strftime format string with shared constant.

* refactor(archive-aggregator): use FILE_TIMESTAMP_MS_FORMAT constant

Replace inline strftime format string with shared constant.

* refactor(batching-reporter): use FILE_TIMESTAMP_FORMAT constant

Replace inline strftime format string with shared constant.

* refactor(progress-reporter): use shared format_timestamp_ms utility

Replace inline timestamp formatting with the shared utility function
for consistent millisecond-precision timestamps.

* refactor(templates): delegate to shared formatting utilities

Replace inline timestamp and duration formatting with calls to the
shared format_timestamp_ms and format_duration utilities.

* refactor(robot-orchestrator): use shared time format and duration utility

Replace inline strftime format string with CONSOLE_TIME_FORMAT constant
and verbose duration formatting with format_duration().

* refactor(robot-parser): use ROBOT_TIMESTAMP_FORMAT constants

Replace inline Robot Framework timestamp format strings with
ROBOT_TIMESTAMP_FORMAT and ROBOT_TIMESTAMP_FORMAT_NO_MS constants.

* refactor(robot-generator): use REPORT_TIMESTAMP_FORMAT constant

Replace inline strftime format string with shared constant.

* refactor(pyats-orchestrator): use shared format_duration utility

Replace 8-line inline duration formatting block with single call
to the shared format_duration() function.

* refactor(summary-printer): replace duplicate format_duration with shared utility

Delete the 16-line SummaryPrinter.format_duration() static method and
import the shared format_duration from utils.formatting instead.
Updates all 4 call sites from self.format_duration() to format_duration().

* refactor(pyats-generator): use REPORT_TIMESTAMP_FORMAT constant

Replace inline strftime format string with shared constant.

* feat(combined-generator): add pre-flight failure report generation

Add CurlTemplate NamedTuple with data-driven lookup replacing if/elif
chain for curl example generation. Add _generate_pre_flight_failure_report()
to route auth and unreachable failures through the unified combined
summary report with 401/403 template branching.

* feat(cli): route auth failures through unified reporting pipeline

Create PreFlightFailure on auth/unreachable and pass to
CombinedReportGenerator instead of standalone auth_failure module.
Replace inline duration formatting with format_duration() call.

* refactor(cli): delete standalone auth_failure reporting module

Auth failure report generation now handled by CombinedReportGenerator.
The standalone cli/reporting/ module is no longer needed.

* test(cli): delete obsolete auth_failure report tests

Test coverage for pre-flight failure reports now lives in
test_combined_generator.py alongside the unified reporting pipeline.

* test(utils): relocate URL extraction tests to tests/unit/utils/

Move extract_host tests from the deleted cli/reporting test module
to tests/unit/utils/test_url.py alongside the utility they test.

* test(combined-generator): add curl template and pre-flight failure tests

Add TestGetCurlExample (4 tests) for data-driven curl template
generation, TestPreFlightFailureReport (6 tests) for auth/unreachable
failure report generation including 401/403 branching and negative
assertion for legacy auth_failure_report.html.

* test(cli): update auth test assertions for unified reporting pipeline

Adjust test mocking and assertions to match the new PreFlightFailure
routing through CombinedReportGenerator instead of standalone module.

* chore: update uv.lock

* fix(reporting): remove redundant f-string wrapper on plain return value

_get_curl_example() wrapped a bare variable in an f-string
(`return f"{controller_url}"`) when no interpolation was needed.
This is flagged by ruff as an unnecessary f-string (F541-adjacent
pattern) and adds runtime overhead for the string copy. Replace with
a direct `return controller_url`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(reporting): restore "Forbidden" text check in is_403 detection

The is_403 flag in _generate_pre_flight_failure_report() only checked
for the numeric "403" string via HTTP_FORBIDDEN_CODE. Some controller
responses include the word "Forbidden" without the numeric code (e.g.,
"Forbidden: insufficient privileges"). The original auth_failure.py
module checked both patterns, but the "Forbidden" text match was lost
during the migration to the unified reporting pipeline.

Restore the disjunction so the template receives is_403=True for both
"403" and "Forbidden" responses, ensuring the HTML report shows the
correct troubleshooting guidance for permission-denied errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(reporting): add explicit encoding="utf-8" to normal-path write_text

The pre-flight failure code path already specified encoding="utf-8"
when writing combined_summary.html, but the normal (success) code
path relied on the platform default. On systems where the default
encoding is not UTF-8 this would produce garbled HTML output.

Make both code paths consistent by specifying encoding="utf-8"
explicitly, matching the pre-flight failure path and the project
convention of always declaring encoding on file I/O.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(reporting): convert f-string logger calls to %-style lazy formatting

Replace all 6 f-string logger calls in CombinedReportGenerator with
%-style formatting (e.g., `logger.info("msg: %s", val)`).

f-strings are evaluated eagerly at the call site regardless of whether
the log level is enabled. %-style defers interpolation to the logging
framework, which skips the string formatting entirely when the message
would be discarded. This is the pattern recommended by the Python
logging documentation and by ruff's LOG002/G004 rule.

Affected calls:
  - generate_combined_summary(): 3 info + 1 error
  - _generate_pre_flight_failure_report(): 1 info + 1 error

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(types): remove has_pre_flight_failure property, use direct None check

Remove the `has_pre_flight_failure` property from CombinedResults and
replace its single call site in CombinedReportGenerator with a direct
`results.pre_flight_failure is not None` check.

Why: mypy cannot narrow the type of an attribute through a property
accessor. The old code required a secondary `if failure is None` guard
(marked "unreachable") just to satisfy mypy after calling
`results.has_pre_flight_failure`. A direct `is not None` check lets
mypy narrow `results.pre_flight_failure` from `PreFlightFailure | None`
to `PreFlightFailure` automatically, eliminating the dead-code guard
entirely.

This also removes one level of indirection — the property was a
single-expression wrapper around `is not None`, adding API surface
without adding clarity. The direct check is idiomatic Python and
immediately communicates what is being tested.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(auth): narrow controller_type from str to ControllerTypeKey Literal

Tighten the type of `AuthCheckResult.controller_type` and the
`preflight_auth_check()` parameter from bare `str` to the
`ControllerTypeKey` Literal alias ("ACI" | "SDWAN" | "CC" | ...).

This eliminates a class of bugs where an arbitrary string could flow
through the auth pipeline unchecked. The `ControllerTypeKey` Literal
constrains valid values at the type level, giving mypy the ability to
catch invalid controller types at analysis time rather than at runtime.

The `cast()` call is placed at the boundary in main.py where
`detect_controller_type()` (which returns `str` because it reads from
environment variables) enters the typed domain. Once cast at this
single entry point, all downstream code — `preflight_auth_check()`,
`AuthCheckResult`, `PreFlightFailure` — receives a properly typed
`ControllerTypeKey` without needing per-site casts.

This removes the redundant `cast(ControllerTypeKey, ...)` that was
previously needed when constructing PreFlightFailure from
auth_result.controller_type, since that field is now already typed
as ControllerTypeKey.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(formatting): restore .2f precision in format_duration

The previous commit inadvertently changed format_duration from .2f to
.1f precision, dropping the second decimal place (e.g., "3.25s" became
"3.3s"). This restores the original .2f format specifier so durations
display with two decimal places, which is more useful for distinguishing
test timings at sub-second granularity.

The docstring examples are updated to reflect the correct output format.

* test(formatting): update format_duration assertions for .2f precision

Updates three test expectations to match the restored .2f format
specifier in format_duration:
  - "1.0s"  -> "1.00s"
  - "45.2s" -> "45.20s"
  - "30.0s" -> "30.00s"

* feat(formatting): add format_file_timestamp_ms() utility function

Three call sites (subprocess_runner, archive_aggregator, base_test)
were each independently doing:

    datetime.now().strftime(FILE_TIMESTAMP_MS_FORMAT)[:-3]

This is a leaky abstraction — callers had to know about the [:-3]
slice that trims microseconds (6 digits) to milliseconds (3 digits),
and each had to import both datetime and the format constant.

The new format_file_timestamp_ms() function encapsulates this pattern
in a single place, consistent with the existing format_timestamp_ms()
function that already handles the display-oriented timestamp format.

The _MICROSECOND_TRIM constant is reused by both functions.

* test(formatting): add tests for format_file_timestamp_ms

Adds TestFormatFileTimestampMs class with four tests covering:
- Known datetime produces exact expected string ("20250627_182616_834")
- Microsecond-to-millisecond trimming works correctly (123456 -> "123")
- None argument defaults to current time (boundary check)
- Output format has the expected 19-character length

These mirror the existing TestFormatTimestampMs structure to maintain
consistency in how we test the two timestamp formatting functions.

* refactor(subprocess-runner): use format_file_timestamp_ms utility

Replaces the inline datetime.now().strftime(FILE_TIMESTAMP_MS_FORMAT)[:-3]
pattern with the new format_file_timestamp_ms() utility. This removes the
need for subprocess_runner to know about the microsecond trimming detail
and drops the now-unused datetime and FILE_TIMESTAMP_MS_FORMAT imports.

* refactor(archive-aggregator): use format_file_timestamp_ms utility

Replaces the inline datetime.now().strftime(FILE_TIMESTAMP_MS_FORMAT)[:-3]
pattern with the new format_file_timestamp_ms() utility. Drops the
now-unused datetime and FILE_TIMESTAMP_MS_FORMAT imports.

This is the second of three call sites migrated to the shared utility.

* refactor(base-test): use format_file_timestamp_ms utility

Replaces the inline strftime + [:-3] slice with format_file_timestamp_ms()
in _generate_test_id(). Removes the now-unused FILE_TIMESTAMP_MS_FORMAT
import from core.constants (datetime import is retained — it is still
used in two other places in this module).

This is the last of three call sites migrated to the shared utility,
completing the elimination of the leaky [:-3] abstraction.

* feat(error-classification): add extract_http_status_code() function

Adds a public function to extract the HTTP status code from an
exception message as an integer, returning None if no code is found.

This reuses the existing _HTTP_STATUS_CODE_PATTERN regex that
_classify_auth_error already uses internally, so the extraction logic
is consistent. The function is intentionally separate from
_classify_auth_error (rather than changing it to return a 3-tuple)
to avoid breaking the 11 existing call sites and their test assertions.

This will be used by the pre-flight auth check to propagate structured
status codes into AuthCheckResult and PreFlightFailure, enabling
reliable is_403 detection without fragile substring matching.

* feat(types): add status_code field to PreFlightFailure

Adds an optional status_code: int | None field to the PreFlightFailure
frozen dataclass. This carries the structured HTTP status code from the
auth check through to the reporting layer, enabling reliable detection
of specific HTTP conditions (e.g., 403 Forbidden) without resorting to
fragile substring matching on the detail string.

The field defaults to None, which is correct for non-HTTP failures like
connection timeouts or DNS resolution errors where there is no HTTP
status code to report.

* feat(auth): add status_code field to AuthCheckResult

Mirrors the status_code field just added to PreFlightFailure. This
allows the pre-flight auth check to capture the HTTP status code at
the point of failure and pass it through to the reporting layer via
PreFlightFailure.

The field defaults to None, which is correct for both successful auth
checks (no error) and non-HTTP failures (timeouts, DNS, etc.).

* feat(auth): extract and propagate HTTP status_code in preflight check

Imports extract_http_status_code and calls it in the exception handler
of preflight_auth_check() to capture the HTTP status code from the
failed auth request. The extracted code is passed into AuthCheckResult
via the new status_code field.

This is the wiring that connects extract_http_status_code (added to
error_classification.py) with the AuthCheckResult field (added in the
previous commit). For non-HTTP errors (timeouts, DNS, etc.),
extract_http_status_code returns None, which is the correct default.

* fix(reporting): replace is_403 substring matching with structured check

The previous is_403 detection used fragile substring matching:

    is_403 = (
        str(HTTP_FORBIDDEN_CODE) in failure.detail
        or "Forbidden" in failure.detail
    )

This could false-positive on any detail string containing "403" or
"Forbidden" as a substring (e.g., "tried 403 times" or a URL path
containing "Forbidden"). It also required the detail string format
to remain stable.

Now that PreFlightFailure carries a structured status_code field,
we can use a direct integer comparison instead:

    is_403 = failure.status_code == HTTP_FORBIDDEN_CODE

This is unambiguous, type-safe, and decoupled from the detail string.

* feat(cli): pass status_code from AuthCheckResult to PreFlightFailure

Threads the status_code field through the CLI main function where
AuthCheckResult is converted into PreFlightFailure. This completes the
data flow from the pre-flight auth check through to the report
generator, where it replaces the fragile substring-based is_403 check.

* test(auth): add status_code propagation tests for preflight check

Adds two tests to TestPreflightAuthCheck:

1. test_propagates_http_status_code — verifies that when auth fails
   with "HTTP 403: Forbidden", the result carries status_code=403.
   This proves the extract_http_status_code -> AuthCheckResult wiring
   works end-to-end.

2. test_status_code_none_for_non_http_errors — verifies that when auth
   fails with a non-HTTP error like "Connection timed out", the
   status_code is None (not some spurious number extracted from the
   error message).

Also adds type: ignore[arg-type] to test_handles_unknown_controller_type
which deliberately passes an invalid controller type string to verify
graceful handling. The ignore is needed because preflight_auth_check now
expects ControllerTypeKey (a Literal type), and "UNKNOWN_CONTROLLER" is
intentionally outside that set — that's the entire point of the test.

* test(error-classification): add tests for extract_http_status_code

Adds TestExtractHttpStatusCode class with five tests covering:
- 401 extraction from "HTTP 401: Unauthorized"
- 403 extraction from "HTTP 403: Forbidden"
- 500 extraction from "HTTP 500: Internal Server Error"
- None returned for non-HTTP messages ("Connection timed out")
- None returned for generic messages ("Something went wrong")

These validate the public extract_http_status_code() function that was
added to error_classification.py, ensuring it correctly reuses the
_HTTP_STATUS_CODE_PATTERN regex to extract status codes and returns
None when no HTTP status code is present.

* test(combined-generator): add status_code to PreFlightFailure fixtures

Updates five PreFlightFailure constructors in the combined generator
tests to include the new status_code field, matching the structured
data that the production code now provides:

- Four auth failure tests: status_code=401 (matching "HTTP 401" detail)
- One 403 test: status_code=403 (matching "HTTP 403: Forbidden" detail)
- Unreachable test: unchanged (no status_code, defaults to None)

This ensures the test fixtures accurately reflect the data shape that
flows through the reporting pipeline in production, where the is_403
check now uses failure.status_code == HTTP_FORBIDDEN_CODE rather than
substring matching on the detail string.

* refactor(reporting): narrow _CURL_TEMPLATES key type to ControllerTypeKey

Changes the _CURL_TEMPLATES dict from dict[str, _CurlTemplate] to
dict[ControllerTypeKey, _CurlTemplate], and narrows the controller_type
parameter of _get_curl_example from str to ControllerTypeKey.

Since _CURL_TEMPLATES keys are always valid controller type literals
("ACI", "SDWAN", "CC"), the type annotation should reflect this.
Using ControllerTypeKey catches typos at type-check time and makes the
data flow consistent with the rest of the reporting pipeline, where
PreFlightFailure.controller_type is already ControllerTypeKey.

* refactor(controller): narrow detect_controller_type return to ControllerTypeKey

Changes the return type of detect_controller_type() from str to
ControllerTypeKey, which is a Literal type covering all supported
controller identifiers ("ACI", "SDWAN", "CC", "MERAKI", etc.).

This eliminates the need for callers to cast() the return value,
since the function now guarantees at the type level that it returns
a valid controller type key.

A cast(ControllerTypeKey, ...) is needed at the return point because
complete_sets is typed as list[str] (populated from CONTROLLER_REGISTRY
dict iteration). The keys are always valid ControllerTypeKey values,
but mypy can't infer this from dict key iteration. The cast documents
this boundary explicitly.

* refactor(cli): remove cast() now that detect_controller_type returns ControllerTypeKey

Now that detect_controller_type() declares its return type as
ControllerTypeKey (the previous commit), the cast() wrapper in main()
is redundant. Removing it also drops the now-unused cast and
ControllerTypeKey imports.

Before: controller_type = cast(ControllerTypeKey, detect_controller_type())
After:  controller_type = detect_controller_type()

The type narrowing is now handled at the source (controller.py) rather
than at every call site — one cast instead of N.

* test(auth): add type: ignore for parametrized controller_type argument

The pytest @parametrize decorator infers controller_type as str, but
preflight_auth_check now expects ControllerTypeKey (a Literal type).
The values ("ACI", "SDWAN", "CC") are all valid ControllerTypeKey
members, but mypy cannot narrow str to Literal through parametrize.

Adding type: ignore[arg-type] is the standard approach for this
pytest + Literal type interaction. The runtime behavior is unchanged.

* feat(banners): add _wrap_url_lines helper for banner content wrapping

Long controller URLs (e.g., https://sandboxdnacultramdeupURL.cisco.com)
can exceed the fixed 78-character banner content width, causing text to
overflow past the right border and breaking the visual box.

This adds a _wrap_url_lines() helper that keeps the URL on the same line
when the combined prefix + URL fits within BANNER_CONTENT_WIDTH, and
wraps the URL to an indented second line when it would overflow. This
approach preserves the fixed-width box (which is important for
consistent terminal rendering across 80-column terminals) while ensuring
all content stays within the borders.

The helper accepts an optional indent parameter for lines that are
already indented (e.g., "  curl -k <url>") so that both the prefix
and wrapped URL line receive consistent leading whitespace.

* fix(banners): wrap long URLs in auth failure banner

The "Could not authenticate to {display_name} at {url}" line is
constructed at runtime, and with long controller URLs (common in
enterprise environments like Catalyst Center sandboxes), the combined
string exceeds the 78-character banner width, causing the right border
to shift out of alignment.

This replaces the single f-string with a _wrap_url_lines() call that
keeps the URL inline for short URLs and wraps it to a second indented
line for long ones. Short URLs (e.g., https://apic.local) render
identically to before — no visual change for the common case.

* fix(banners): wrap long URLs in unreachable banner

The unreachable banner has two lines that embed the controller URL:
the "Could not connect to..." message and the "curl -k <url>" command.
Both can overflow the 78-character banner width with long URLs.

This applies _wrap_url_lines() to both lines. The curl command uses the
indent="  " parameter so both the "curl -k" prefix and the wrapped URL
receive the 2-space indent that visually groups them under the
"Verify the controller is reachable..." instruction line.

The ping command is not wrapped because it uses the extracted hostname
(not the full URL), which is always short enough to fit.

* test(banners): add unit tests for _wrap_url_lines helper

Tests the four key behaviors of the URL wrapping helper:

1. Short URLs that fit within BANNER_CONTENT_WIDTH stay on one line,
   preserving the original single-line format for the common case.

2. Long URLs that exceed the width are wrapped to a second line with
   2-space indentation, keeping the prefix on its own line.

3. The indent parameter is applied to both lines when wrapping occurs,
   so indented commands like "  curl -k" produce "  curl -k" / "    <url>"
   with the URL indented 2 more spaces relative to the prefix.

4. The indent parameter is applied to the single line when no wrapping
   is needed, maintaining consistent indentation.

Each test explicitly verifies its precondition (e.g., that the test URL
actually exceeds the width) to prevent tests from silently passing if
BANNER_CONTENT_WIDTH changes in the future.

* test(banners): add end-to-end tests for long URL banner rendering

These tests verify the actual rendered banner output — not just the
helper function — to ensure no line overflows the box border when a
long controller URL is used. This catches regressions that unit tests
on _wrap_url_lines alone would miss, such as a new banner line being
added that embeds the URL without using the wrapping helper.

Each test uses the same long URL from the original bug report
(https://sandboxdnacultramdeupURL.cisco.com) and asserts that every
rendered line is at most BANNER_CONTENT_WIDTH + 2 characters (the
content width plus the two border characters).

Both display_unreachable_banner and display_auth_failure_banner are
covered since they have different content layouts but the same overflow
risk.

* fix(cli): skip controller detection and preflight auth in dry-run mode

Dry-run mode doesn't need controller access, same as render-only.
Gate both detect_controller_type() and preflight_auth_check() behind
`not render_only and not dry_run` in main() and CombinedOrchestrator.

* fix(tests): mock preflight auth check in integration tests

The integration tests use bogus ACI credentials (ACI_URL=foo) which now
trigger the preflight auth check added in the previous commit. Since
these tests validate Robot rendering/execution/ordering behavior (not
authentication), mock both detect_controller_type and preflight_auth_check
in the existing setup_bogus_controller_env autouse fixtures.

* fix(tests): fix auth cache lock file test for filelock >= 3.13

filelock 3.24.2 (UnixFileLock) auto-deletes .lock files on release,
so the lock file does not persist between separate FileLock acquisitions.
The test incorrectly asserted .lock file existence after _cache_auth_data()
returned — this was testing filelock library behavior, not our code.

Updated to verify the end state: no cache or lock artifacts remain after
invalidation, regardless of filelock's cleanup behavior.

* refactor: move preflight auth check from CLI to CombinedOrchestrator

The nac-test CLI should remain a generic testing engine (Jinja + pabot)
that isn't tied to infrastructure controllers. This moves controller
detection and preflight auth validation from main.py into
CombinedOrchestrator.run_tests(), gated by has_pyats and not
render_only/dry_run.

Changes:
- Remove controller detection and auth check block from cli/main.py
- Add preflight auth check to CombinedOrchestrator.run_tests()
- Consolidate controller detection into run_tests() flow
- Add shared integration test conftest with setup_bogus_controller_env
- Use pytest.mark.usefixtures for selective fixture application
- Remove redundant auth mocks from unit tests (CombinedOrchestrator
  is fully mocked via @patch)
- Update test assertions for new orchestrator-level auth handling

* fix: skip preflight auth check in dry-run mode

Dry-run mode validates test structure, not execution — it should not
require controller credentials. The auth gate in run_tests() now
checks `not self.dry_run` alongside `not self.render_only`.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
oboehmer pushed a commit that referenced this pull request Mar 19, 2026
* Add shared architecture detection helper for validators

Introduces is_architecture_active() helper function that provides a
lightweight check for whether an architecture environment is active by
testing if its URL environment variable is set.

This helper is shared across all architecture-specific validators (ACI,
SD-WAN, Catalyst Center) to avoid duplicating environment detection logic.

Rationale: Validators need to know which architecture is active to decide
whether to perform validation. Centralizing this check in common.py ensures
consistency and makes adding new architecture validators straightforward.

* Add ACI defaults validation with two-stage heuristic

Implements validate_aci_defaults() that fails fast when users forget to
pass the ACI defaults file, catching the common mistake of omitting
-d ./defaults/ before expensive data merge operations.

Two-stage validation approach:

Stage 1 (Instant path-based heuristic):
- Checks if path contains "default" or "defaults" as directory component
- For files: requires .yaml/.yml extension + "default" in filename
- Rejects false positives like "/users/defaultuser/" or "config.txt"

Stage 2 (Content-based fallback):
- Parses YAML files to find "defaults.apic" structure
- Non-recursive directory scanning for performance
- Handles both .yaml and .yml extensions

Security features:
- Rejects symlinks to prevent path traversal
- Limits file scanning to 3MB to prevent memory exhaustion

Performance: Path heuristic provides instant feedback for 95% of cases.
Content scanning only runs when path-based check doesn't match, adding
minimal overhead (~50-100ms) compared to time saved when defaults are
missing (users get immediate feedback instead of waiting for full merge).

* Add validators package public API exports

Exports validate_aci_defaults() and is_architecture_active() as the
public API for the validators package.

Enforces separation of concerns: validators package only exports
validation functions, not UI components. Display functions like
display_aci_defaults_banner() should be imported directly from
nac_test.cli.ui, maintaining clear architectural boundaries between
validation logic and presentation.

This design allows adding new architecture validators (SD-WAN, Catalyst
Center) by following the same pattern without coupling to UI concerns.

* Add terminal banner display for ACI defaults error

Implements display_aci_defaults_banner() to show a prominent, user-friendly
error message when ACI defaults file is missing.

BoxStyle dataclass design:
- Encapsulates box-drawing characters for terminal rendering
- Frozen dataclass ensures immutability
- Supports both ASCII (fallback) and Unicode (modern terminals)
- Includes emoji_adjustment field to handle Unicode width quirks

NO_COLOR environment variable support:
- Respects NO_COLOR standard for accessibility
- Falls back to plain ASCII when NO_COLOR is set or Unicode unavailable

Banner content includes:
- Clear error message explaining the requirement
- Working example command showing correct syntax
- Link to Cisco ACI documentation for context
- Professional formatting with borders and sections

Rationale: Validation errors need to be immediately visible and actionable.
A prominent banner with example command helps users fix the issue quickly
instead of hunting through error logs.

* Add UI package public API for banner display

Exports display_aci_defaults_banner() as the public interface for CLI
error banners. Future banner functions (SD-WAN, Catalyst Center) will
be exported from this package.

Architectural separation: UI package handles all user-facing display
logic, while validators package handles business logic. This separation
allows testing and modifying display behavior independently from
validation rules.

* Wire ACI defaults validation into CLI entry point

Adds validation check immediately before DataMerger.merge_data_files()
to fail fast when defaults are missing. Validation runs after output
directory creation but before expensive merge operation.

Execution flow:
1. Parse CLI arguments
2. Configure logging
3. Create output directory
4. → NEW: Validate ACI defaults (instant check)
5. Merge data files (expensive operation, 2-5 seconds)
6. Continue with orchestrator...

Error handling:
- Validation failure displays prominent banner via display_aci_defaults_banner()
- Exits with code 1 (failure) to prevent continuing with invalid config
- Blank lines before/after banner improve readability

Time savings: Users get immediate feedback instead of waiting 2-5 seconds
for data merge to complete before seeing validation error. Pre-flight check
philosophy: validate prerequisites before expensive operations.

* Add test directory structure for CLI components

Creates package structure mirroring source code organization:
- tests/unit/cli/validators/ for validator unit tests
- tests/unit/cli/ui/ for UI component unit tests

Empty __init__.py files make directories importable Python packages,
allowing pytest to discover tests and enabling relative imports within
test modules.

Test organization principle: Test structure should mirror source
structure. This makes finding tests for a given module intuitive and
maintains clear architectural boundaries in both source and test code.

* Add comprehensive unit tests for ACI defaults validator

Tests validate_aci_defaults() and helper functions with 32 test cases
covering environment detection, path matching, YAML parsing, security
features, and edge cases.

Test coverage includes:

Environment detection (3 tests):
- Validation skips when ACI_URL not set
- Validation runs when ACI_URL is set
- Empty string ACI_URL treated as not set

Path-based heuristic (9 tests):
- Matches "defaults" directory component
- Matches defaults.yaml and defaults.yml files
- Rejects non-YAML files (defaults.txt, defaults.json)
- Rejects substring matches (defaultuser, my-defaulted-config)
- Case-insensitive matching for directory names

YAML content validation (8 tests):
- Finds defaults.apic structure in YAML files
- Handles both .yaml and .yml extensions
- Rejects files missing "defaults:" or "apic:" keys
- Handles empty files and unreadable files gracefully

Security features (2 tests):
- Rejects symlinks to prevent path traversal
- Skips files larger than 3MB to prevent memory exhaustion

Performance features (1 test):
- Directory scanning is non-recursive (documented design decision)

Edge cases (9 tests):
- Two-stage validation (path check → content check)
- Multiple data paths with mixed content
- Content check runs as fallback when path check fails

Test quality: Uses real filesystem operations (tmp_path) not mocks,
validates actual application behavior not library functionality,
includes comprehensive docstrings documenting test intent.

* Add unit tests for ACI defaults banner display

Tests display_aci_defaults_banner() function with focus on output
content, NO_COLOR environment variable support, and error handling.

Test coverage includes:

Banner content validation (3 tests):
- Banner displays without raising exceptions
- Banner contains required error message text
- Banner includes example command with correct syntax

NO_COLOR support (2 tests):
- ASCII box style used when NO_COLOR environment variable is set
- Unicode box style used when NO_COLOR is not set

Test approach: Uses pytest's capsys fixture to capture stdout and
validate actual terminal output. Tests verify real banner display
behavior, not mocked components.

Note: Banner tests focus on content validation (error message, example
command) rather than exact formatting (border characters, spacing).
This design allows banner styling to evolve without breaking tests as
long as essential content remains present.

* Add integration tests for CLI ACI defaults validation

Implements 6 integration tests split into two test classes validating
end-to-end CLI behavior with ACI defaults validation.

TestCliAciValidationIntegration (4 tests using CliRunner):
- Tests validation triggers when ACI_URL set without defaults
- Tests validation skips when ACI_URL not set
- Tests validation passes when defaults directory provided
- Tests validation passes when YAML contains defaults.apic structure

TestCliAciValidationSubprocess (2 tests using subprocess.run):
- Tests actual nac-test CLI process execution with ACI_URL
- Tests CLI subprocess without ACI_URL environment variable
- Marked with skipif when CLI not installed in PATH

Integration test design:

CliRunner tests (lines 26-195):
- Use Typer's CliRunner for fast integration testing
- Mock expensive operations (DataMerger, CombinedOrchestrator)
- Validate CLI wiring: arguments → validators → UI → exit codes
- Test focus: Does validation logic integrate correctly with CLI?

Subprocess tests (lines 197-301):
- Execute real nac-test command in subprocess
- Zero mocking - tests production behavior
- Validate complete process: entry point → imports → execution → exit
- Test focus: Does CLI actually work end-to-end in production?

Test fixtures:
- clean_controller_env: Clears environment variables for isolation
- minimal_test_env: Creates temporary directories and YAML files
- cli_test_env: Subprocess-specific environment setup

Coverage: Tests validate that validation runs at correct point in CLI
flow (after argument parsing, before data merge), displays banner on
failure, returns correct exit codes, and handles environment variables.

* Fix CI: Add defaults.yaml to integration test fixtures

Integration tests in test_integration_robot_pabot.py set ACI_URL
environment variable via setup_bogus_controller_env fixture, which
triggers the new ACI defaults validation.

Adding defaults.yaml to test fixtures satisfies the validation check
without modifying test logic or environment setup. The file contains
minimal defaults.apic structure required by the validator.

This fixes 20 failing integration tests that were exiting with code 1
(validation failure) instead of code 0 (success).

* Fix CI: Add defaults.yaml to all integration test fixture directories

Additional fixture directories (data_env, data_list, data_list_chunked,
data_merge) also used by integration tests need defaults.yaml to satisfy
ACI validation when ACI_URL environment variable is set.

Ensures all 20 failing integration tests pass by providing the minimal
defaults.apic structure required by the validator in all test fixture
data directories.

* Fix CI: Add defaults.apic structure to data_merge test fixtures

The test_nac_test_render_output_model test passes individual YAML files
(file1.yaml, file2.yaml) via -d flags instead of a directory. Our
validator performs content-based checking on these files.

Adding defaults.apic structure to file1.yaml satisfies the validator's
content check. The expected result.yaml is updated to include the
defaults structure since it's part of the merged data model.

This fixes the 2 remaining failing integration tests.

* Add defaults.yaml to PyATS quicksilver test fixtures

The test_nac_test_pyats_quicksilver_api_only tests set architecture-specific
URL environment variables (ACI_URL, CC_URL, SDWAN_URL) which trigger the
ACI defaults validation. These fixture directories need minimal defaults.yaml
files with the required structure to satisfy the validation check.

Files added:
- tests/integration/fixtures/data_pyats_qs/aci/defaults.yaml
- tests/integration/fixtures/data_pyats_qs/catc/defaults.yaml
- tests/integration/fixtures/data_pyats_qs/sdwan/defaults.yaml

Each contains minimal mock structure: defaults.apic.version = 6.0

This fixes the failing CI test: test_nac_test_pyats_quicksilver_api_only[aci-ACI-1-0-0]

* fix(tests): update test imports for Phase 1 refactoring

Updated test files to use new names introduced in Phase 1:
- CONTROLLER_REGISTRY instead of separate dict constants
- extract_host() instead of _extract_host()
- Updated test class and test assertions for dataclass-based config

Added type: ignore comments for untyped Auth class methods to satisfy mypy.

All 64 tests now pass successfully.

* refactor: create centralized HTTP status constants module

Introduces core/http_constants.py as the single source of truth for HTTP
status code range boundaries and special status code sets used throughout
the framework.

Why this improves the codebase:
- Eliminates DRY violations where HTTP status ranges were defined in
  multiple locations (subprocess_client.py, http/__init__.py)
- Provides consistent status code classification logic
- Makes it easier to maintain and update HTTP-related constants
- Improves type safety with explicitly typed constants
- Centralizes authentication failure codes (401, 403) and service
  unavailable codes (408, 429, 503, 504) for reuse

This module serves as a foundation for other refactoring work that will
migrate existing code to use these centralized constants.

* refactor: migrate subprocess_client.py to centralized HTTP constants

Updates subprocess_client.py to import and use HTTP status code constants
from the new core/http_constants.py module instead of defining them locally.

Why this improves the codebase:
- Eliminates duplication of HTTP status range definitions
- Ensures consistent status code classification across the framework
- Makes future updates to HTTP constants affect all consumers automatically
- Improves maintainability by reducing places where constants are defined

This is part of a broader effort to establish core/http_constants.py as
the single source of truth for HTTP-related constants.

* refactor: update http/__init__.py to re-export centralized constants

Updates the http package's __init__.py to re-export HTTP constants from
core/http_constants.py rather than defining them locally. This maintains
backward compatibility for any code importing from nac_test.pyats_core.http.

Why this improves the codebase:
- Preserves existing import paths for backward compatibility
- Delegates to the single source of truth (core/http_constants.py)
- Reduces maintenance burden by having only one place to update constants
- Makes the http package a thin facade over the core constants module

This change ensures that code using either import path gets the same
constants, preventing potential inconsistencies.

* refactor: create URL parsing utility module

Introduces utils/url.py with extract_host() function to provide robust URL
parsing capabilities using Python's standard library urlparse.

Why this improves the codebase:
- Centralizes URL manipulation logic previously duplicated or ad-hoc
- Uses Python's battle-tested urlparse for robust parsing (handles edge
  cases like missing schemes, IPv6 addresses, port numbers)
- Provides consistent host extraction across the codebase
- Eliminates brittle string manipulation approaches (split('/'), regex)
- Improves testability by isolating URL parsing logic
- Makes the codebase more maintainable by having a single utility for URL operations

This utility will be used by auth_failure.py, controller_auth.py, and
potentially other modules that need to extract host information from URLs.

* refactor: create error classification module

Introduces core/error_classification.py to extract and centralize
authentication error classification logic. This module provides AuthOutcome
enum and classification functions for HTTP and network errors.

Why this improves the codebase:
- Separates error classification logic from controller authentication logic
  (Single Responsibility Principle)
- Makes error classification logic highly testable in isolation
- Uses a two-tier classification strategy: check network failures first
  (timeouts, connection refused, DNS) to avoid false positives from port
  numbers being matched as HTTP status codes
- Provides structured outcome classification via AuthOutcome enum instead
  of raw strings
- Centralizes HTTP status code interpretation logic (401/403 → bad
  credentials, 408/429/503/504 → unreachable, etc.)
- Uses regex for reliable HTTP status code extraction from error messages
- Improves maintainability by having one place to update classification rules

This module will be used by controller_auth.py and auth_failure.py to
provide consistent error categorization across the framework.

* refactor: add controller metadata helpers and structured config

Adds ControllerConfig dataclass and CONTROLLER_REGISTRY to centralize
controller metadata, along with two new helper functions:
get_display_name() and get_env_var_prefix().

Why this improves the codebase:
- Eliminates DRY violations where controller display names and env var
  prefixes were hardcoded in 5+ locations across the codebase
- Provides a single source of truth (CONTROLLER_REGISTRY) for all
  controller metadata (display names, URL env vars, credential patterns)
- Introduces ControllerConfig dataclass for type-safe controller metadata
- Makes controller information easily accessible via simple helper functions
- Improves maintainability: adding new controllers or changing display
  names now requires updates in only one place
- Preserves backward compatibility by maintaining CREDENTIAL_PATTERNS as
  a view over CONTROLLER_REGISTRY

The helper functions gracefully degrade by returning the controller_type
string if a controller is not in the registry, preventing crashes when
dealing with unknown controller types.

* refactor: fix type annotation and extract banner rendering logic

Fixes the ColorValue type annotation in banners.py (was incorrectly using
int | tuple, now correctly str | int | tuple) and extracts common banner
rendering logic into a reusable _render_banner() function.

Why this improves the codebase:
- Corrects type annotation to match typer's actual color value type (must
  include str for named colors like "red", "white")
- Eliminates massive code duplication across banner display functions by
  extracting shared rendering logic into _render_banner()
- Reduces line count by ~100+ lines while preserving all functionality
- Makes banner rendering logic testable in isolation
- Improves maintainability: changes to border rendering now affect all
  banners automatically
- Uses controller helper functions (get_display_name, extract_host) to
  eliminate hardcoded controller names
- Follows DRY principle by having a single implementation of box-drawing
  character handling and color application

All public banner functions now delegate to _render_banner() with
appropriate title, content, and color parameters.

* refactor: migrate main.py to use controller helper functions

Updates main.py to use get_display_name() and get_env_var_prefix() from
utils.controller instead of hardcoding controller names and prefixes.

Why this improves the codebase:
- Eliminates hardcoded controller display names (no more "SDWAN Manager"
  string literals scattered throughout)
- Reduces coupling between main.py and controller-specific knowledge
- Makes the code more maintainable: adding/changing controller names now
  only requires updates to CONTROLLER_REGISTRY
- Improves consistency by ensuring all code uses the same display names
- Follows DRY principle by delegating to centralized helper functions

This is part of a broader effort to eliminate controller metadata
duplication across the codebase.

* refactor: migrate auth_failure.py to use centralized utilities

Updates auth_failure.py to use get_display_name() from utils.controller
and extract_host() from utils.url instead of defining logic inline.

Why this improves the codebase:
- Eliminates hardcoded controller display names
- Replaces brittle URL host extraction (split('/')) with robust urlparse
- Reduces coupling between auth_failure.py and controller-specific logic
- Improves testability by delegating to tested utility functions
- Makes the code more maintainable and consistent with the rest of the
  codebase
- Follows DRY principle by reusing centralized utilities

This change ensures auth_failure.py uses the same display names and URL
parsing logic as the rest of the framework.

* test: update controller_auth tests for new module structure

Updates test_controller_auth.py to reflect the refactored module structure
where error classification and controller registry moved to separate modules.

Why this improves the test suite:
- Removes tests for AuthOutcome enum (now tested in test_error_classification.py)
- Removes tests for AuthCheckResult dataclass (simple dataclass, no logic)
- Removes tests for ControllerConfig (now tested in test_controller.py)
- Focuses tests on the actual validation logic in controller_auth.py
- Follows the principle of testing behavior, not implementation details
- Imports CONTROLLER_REGISTRY and AuthOutcome from their new locations
- Maintains test coverage of the actual authentication validation flow

This change aligns the test suite with the refactored module boundaries,
ensuring each module's tests focus on that module's responsibilities.

* test: update banners tests for extracted rendering logic

Updates test_banners.py to test the new _render_banner() helper function
and ensures all banner functions still work correctly after refactoring.

Why this improves the test suite:
- Tests the extracted _render_banner() function directly to verify common
  banner rendering logic
- Ensures all public banner functions still produce correct output after
  delegating to _render_banner()
- Maintains coverage of NO_COLOR environment variable handling
- Tests that controller helper functions are used correctly (get_display_name)
- Follows DRY principle in tests by verifying shared logic once rather
  than duplicating assertions across multiple banner function tests

This change ensures the refactored banner code maintains the same
behavior while having better test isolation for the extracted logic.

* test: update auth_failure tests for new utility imports

Updates test_auth_failure.py to reflect that auth_failure.py now imports
display names and URL extraction from centralized utility modules.

Why this improves the test suite:
- Aligns test imports with the refactored module structure
- Tests continue to verify that auth_failure.py uses correct display
  names and URL extraction logic
- Ensures coverage of the integration between auth_failure.py and the
  new utility functions (get_display_name, extract_host)
- Maintains test isolation while verifying correct delegation to utilities

This change ensures auth_failure.py tests remain accurate after the
refactoring to use centralized utilities.

* test: move test_cli_controller_auth.py to correct directory

Moves test_cli_controller_auth.py from tests/integration/ to tests/unit/cli/
to reflect its actual testing scope and organization.

Why this improves the test suite:
- Places test file in the correct directory structure matching the module
  it tests (cli/validators/controller_auth.py)
- Improves test discoverability: unit tests for cli modules should be
  under tests/unit/cli/
- Separates unit tests from integration tests more clearly
- Follows the convention of mirroring the source tree structure in the
  test directory
- Makes it easier for developers to find relevant tests

This is a pure organizational change with no logic modifications.

* fix(tests): resolve Python 3.10 mock.patch compatibility issue

Updates three banner tests to patch TerminalColors.NO_COLOR at the
import location (nac_test.cli.ui.banners) rather than the definition
location (nac_test.utils.terminal).

Python 3.10's mock.patch implementation has issues resolving nested
class attributes when patching at the definition location, causing:
  ModuleNotFoundError: No module named 'nac_test.utils.terminal.TerminalColors'

Python 3.11+ handles this correctly, but for 3.10 compatibility,
patching at the import location is the standard best practice.

Fixes CI failures in Tests (3.10):
- TestDisplayAciDefaultsBanner::test_respects_no_color_mode
- TestDisplayAuthFailureBanner::test_respects_no_color_mode
- TestDisplayUnreachableBanner::test_respects_no_color_mode

* fix(auth): protect sys.argv during pyats-common lazy imports

PyATS's configuration module parses sys.argv at import time,
registering --pyats-configuration as a known argument. When
_get_auth_callable() lazily imports from nac_test_pyats_common,
the parent package __init__.py eagerly imports all test base
classes, which triggers `from pyats import aetest`, initializing
the configuration module. Python argparse prefix-matches our
--pyats CLI flag to --pyats-configuration, causing:

  error: argument --pyats-configuration: expected one argument

Fix: temporarily strip --pyats from sys.argv during the import
and restore it in a finally block. This follows the established
pattern from device_inventory.py (lines 94-100).

* Add cache invalidation method to AuthCache

Implement AuthCache.invalidate() to support credential change detection
in preflight authentication checks. This method safely removes cached
tokens under file lock, enabling fresh authentication when credentials
are updated between test runs.

Benefits:
- Prevents stale token reuse when credentials change
- Process-safe with FileLock for parallel test execution
- Best-effort design never blocks test execution
- Automatic cleanup of both cache and lock files

The invalidation mechanism is designed for the common workflow where
users test with wrong credentials (cache miss → auth fails), then fix
credentials (invalidate → cache miss → auth succeeds), then test again
with different wrong credentials (invalidate → cache miss → auth fails).
Without this, the third scenario would incorrectly reuse the cached
token from the second run.

* Add cache_key field to ControllerConfig

Introduce cache_key mapping to centralize the translation between
detected controller types and their AuthCache storage keys. This
resolves the naming inconsistency where SDWAN detection returns
"SDWAN" but the adapter uses "SDWAN_MANAGER" as the cache key.

Benefits:
- Single source of truth for cache key mapping
- Eliminates hardcoded key strings in multiple locations
- Supports all three controller types (ACI, SDWAN, CC)
- Enables preflight check to invalidate the correct cache file

The mapping preserves existing cache behavior while making it
explicit and maintainable. Cache keys match the actual keys used
by authentication adapters in nac-test-pyats-common.

* Invalidate auth cache before preflight credential check

Integrate cache invalidation into the preflight authentication flow
to ensure fresh credential validation on every test run. This fixes
the issue where changing credentials between runs would incorrectly
reuse cached tokens from previous successful authentications.

Benefits:
- Detects credential changes immediately (no stale token reuse)
- Validates current env var credentials, not cached credentials
- Works across all controller types (ACI, SDWAN, CC)
- Best-effort design never blocks execution if invalidation fails

The fix enables the critical workflow:
  Run 1: Wrong creds → Preflight fails, banner shown, exit
  Run 2: Correct creds → Preflight succeeds, tests run, cache written
  Run 3: Wrong creds again → Cache invalidated, preflight fails, banner

Without this fix, Run 3 would incorrectly succeed using the cached
token from Run 2, allowing tests to execute with invalid credentials
in the environment.

* Add unit tests for AuthCache.invalidate()

Implement comprehensive unit tests covering the cache invalidation
method's behavior across success, no-op, and failure scenarios.

Test coverage:
- Successful cache file removal with lock acquisition
- No-op behavior when cache file doesn't exist
- Lock file cleanup after cache removal
- Graceful handling of permission errors (best-effort)

These tests validate the core invalidation logic that enables
preflight credential change detection while ensuring failures
never block test execution.

* Add unit tests for preflight cache invalidation integration

Implement unit tests validating the preflight authentication check's
integration with cache invalidation across all controller types.

Test coverage:
- ACI, SDWAN, and CC cache invalidation during preflight
- Correct cache_key used for each controller type
- No invalidation attempted for unsupported controller types
- Invalidation failures do not prevent authentication attempts

These tests ensure the preflight check correctly invalidates cached
tokens before validating current credentials, enabling immediate
detection of credential changes across all supported architectures.

* Add integration tests for preflight controller authentication

Add pytest-httpserver-based integration tests that exercise the full
auth chain: preflight_auth_check → auth adapter → subprocess → HTTP
request → error classification. Tests cover all three controller types
(APIC, SDWAN, Catalyst Center) with success and failure paths.

Test coverage:
- APIC: success (200), bad credentials (401), forbidden (403), unreachable
- SDWAN: success with session cookie + XSRF token, bad credentials (401)
- CC: success with Basic Auth, bad credentials (401 on both endpoints)

Also adds pytest-httpserver to dev dependencies and registers the
'slow' pytest marker for tests with real network timeouts.

* fix(cli): clean up preflight auth banner output

Remove verbose error detail lines from auth failure and unreachable
banners — the summary line ("Could not authenticate/connect to X at Y")
is sufficient for user-facing output, and the raw error strings were
overflowing the box borders.

Downgrade subprocess_auth and controller_auth log messages from
ERROR/WARNING to DEBUG — the banner already handles user-facing
output, so the log lines were redundant noise. Also remove the
double "Authentication failed:" prefix wrapping in SubprocessAuthError.

* fix: replace hardcoded "APIC recovery" with "controller recovery" in retry logs

The retry backoff messages referenced "APIC recovery" which is
incorrect for SD-WAN and Catalyst Center controllers. Changed to
the controller-agnostic "controller recovery" so the messages are
accurate regardless of which architecture is being tested.

* feat(types): add PreFlightFailure dataclass and ControllerTypeKey alias

Introduce frozen dataclass for pre-flight failure metadata with
Literal-typed failure_type field, and a ControllerTypeKey type alias
for consistent controller type handling across the codebase.

* refactor(constants): consolidate HTTP constants into core/constants.py

Move HTTP status code ranges, auth failure codes, and service
unavailable codes from the standalone http_constants module into
the shared constants module. Update all import sites.

* feat(utils): add shared duration and timestamp formatting utilities

Add format_duration() for smart human-readable duration display
(<1s, seconds, minutes, hours) and format_timestamp_ms() for
millisecond-precision timestamps. Single source of truth for
formatting logic used across CLI, progress, and reporting.

* test(utils): add unit tests for format_duration and format_timestamp_ms

Cover all formatting tiers: None/N/A, sub-second, seconds, minutes,
hours for duration; millisecond precision and default-to-now for
timestamps. 14 test cases total.

* refactor(base-test): use timestamp format constants instead of inline strings

Replace hardcoded strftime format strings with FILE_TIMESTAMP_FORMAT
and FILE_TIMESTAMP_MS_FORMAT from core constants.

* refactor(subprocess-runner): use FILE_TIMESTAMP_MS_FORMAT constant

Replace inline strftime format string with shared constant.

* refactor(archive-aggregator): use FILE_TIMESTAMP_MS_FORMAT constant

Replace inline strftime format string with shared constant.

* refactor(batching-reporter): use FILE_TIMESTAMP_FORMAT constant

Replace inline strftime format string with shared constant.

* refactor(progress-reporter): use shared format_timestamp_ms utility

Replace inline timestamp formatting with the shared utility function
for consistent millisecond-precision timestamps.

* refactor(templates): delegate to shared formatting utilities

Replace inline timestamp and duration formatting with calls to the
shared format_timestamp_ms and format_duration utilities.

* refactor(robot-orchestrator): use shared time format and duration utility

Replace inline strftime format string with CONSOLE_TIME_FORMAT constant
and verbose duration formatting with format_duration().

* refactor(robot-parser): use ROBOT_TIMESTAMP_FORMAT constants

Replace inline Robot Framework timestamp format strings with
ROBOT_TIMESTAMP_FORMAT and ROBOT_TIMESTAMP_FORMAT_NO_MS constants.

* refactor(robot-generator): use REPORT_TIMESTAMP_FORMAT constant

Replace inline strftime format string with shared constant.

* refactor(pyats-orchestrator): use shared format_duration utility

Replace 8-line inline duration formatting block with single call
to the shared format_duration() function.

* refactor(summary-printer): replace duplicate format_duration with shared utility

Delete the 16-line SummaryPrinter.format_duration() static method and
import the shared format_duration from utils.formatting instead.
Updates all 4 call sites from self.format_duration() to format_duration().

* refactor(pyats-generator): use REPORT_TIMESTAMP_FORMAT constant

Replace inline strftime format string with shared constant.

* feat(combined-generator): add pre-flight failure report generation

Add CurlTemplate NamedTuple with data-driven lookup replacing if/elif
chain for curl example generation. Add _generate_pre_flight_failure_report()
to route auth and unreachable failures through the unified combined
summary report with 401/403 template branching.

* feat(cli): route auth failures through unified reporting pipeline

Create PreFlightFailure on auth/unreachable and pass to
CombinedReportGenerator instead of standalone auth_failure module.
Replace inline duration formatting with format_duration() call.

* refactor(cli): delete standalone auth_failure reporting module

Auth failure report generation now handled by CombinedReportGenerator.
The standalone cli/reporting/ module is no longer needed.

* test(cli): delete obsolete auth_failure report tests

Test coverage for pre-flight failure reports now lives in
test_combined_generator.py alongside the unified reporting pipeline.

* test(utils): relocate URL extraction tests to tests/unit/utils/

Move extract_host tests from the deleted cli/reporting test module
to tests/unit/utils/test_url.py alongside the utility they test.

* test(combined-generator): add curl template and pre-flight failure tests

Add TestGetCurlExample (4 tests) for data-driven curl template
generation, TestPreFlightFailureReport (6 tests) for auth/unreachable
failure report generation including 401/403 branching and negative
assertion for legacy auth_failure_report.html.

* test(cli): update auth test assertions for unified reporting pipeline

Adjust test mocking and assertions to match the new PreFlightFailure
routing through CombinedReportGenerator instead of standalone module.

* chore: update uv.lock

* fix(reporting): remove redundant f-string wrapper on plain return value

_get_curl_example() wrapped a bare variable in an f-string
(`return f"{controller_url}"`) when no interpolation was needed.
This is flagged by ruff as an unnecessary f-string (F541-adjacent
pattern) and adds runtime overhead for the string copy. Replace with
a direct `return controller_url`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(reporting): restore "Forbidden" text check in is_403 detection

The is_403 flag in _generate_pre_flight_failure_report() only checked
for the numeric "403" string via HTTP_FORBIDDEN_CODE. Some controller
responses include the word "Forbidden" without the numeric code (e.g.,
"Forbidden: insufficient privileges"). The original auth_failure.py
module checked both patterns, but the "Forbidden" text match was lost
during the migration to the unified reporting pipeline.

Restore the disjunction so the template receives is_403=True for both
"403" and "Forbidden" responses, ensuring the HTML report shows the
correct troubleshooting guidance for permission-denied errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(reporting): add explicit encoding="utf-8" to normal-path write_text

The pre-flight failure code path already specified encoding="utf-8"
when writing combined_summary.html, but the normal (success) code
path relied on the platform default. On systems where the default
encoding is not UTF-8 this would produce garbled HTML output.

Make both code paths consistent by specifying encoding="utf-8"
explicitly, matching the pre-flight failure path and the project
convention of always declaring encoding on file I/O.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(reporting): convert f-string logger calls to %-style lazy formatting

Replace all 6 f-string logger calls in CombinedReportGenerator with
%-style formatting (e.g., `logger.info("msg: %s", val)`).

f-strings are evaluated eagerly at the call site regardless of whether
the log level is enabled. %-style defers interpolation to the logging
framework, which skips the string formatting entirely when the message
would be discarded. This is the pattern recommended by the Python
logging documentation and by ruff's LOG002/G004 rule.

Affected calls:
  - generate_combined_summary(): 3 info + 1 error
  - _generate_pre_flight_failure_report(): 1 info + 1 error

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(types): remove has_pre_flight_failure property, use direct None check

Remove the `has_pre_flight_failure` property from CombinedResults and
replace its single call site in CombinedReportGenerator with a direct
`results.pre_flight_failure is not None` check.

Why: mypy cannot narrow the type of an attribute through a property
accessor. The old code required a secondary `if failure is None` guard
(marked "unreachable") just to satisfy mypy after calling
`results.has_pre_flight_failure`. A direct `is not None` check lets
mypy narrow `results.pre_flight_failure` from `PreFlightFailure | None`
to `PreFlightFailure` automatically, eliminating the dead-code guard
entirely.

This also removes one level of indirection — the property was a
single-expression wrapper around `is not None`, adding API surface
without adding clarity. The direct check is idiomatic Python and
immediately communicates what is being tested.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(auth): narrow controller_type from str to ControllerTypeKey Literal

Tighten the type of `AuthCheckResult.controller_type` and the
`preflight_auth_check()` parameter from bare `str` to the
`ControllerTypeKey` Literal alias ("ACI" | "SDWAN" | "CC" | ...).

This eliminates a class of bugs where an arbitrary string could flow
through the auth pipeline unchecked. The `ControllerTypeKey` Literal
constrains valid values at the type level, giving mypy the ability to
catch invalid controller types at analysis time rather than at runtime.

The `cast()` call is placed at the boundary in main.py where
`detect_controller_type()` (which returns `str` because it reads from
environment variables) enters the typed domain. Once cast at this
single entry point, all downstream code — `preflight_auth_check()`,
`AuthCheckResult`, `PreFlightFailure` — receives a properly typed
`ControllerTypeKey` without needing per-site casts.

This removes the redundant `cast(ControllerTypeKey, ...)` that was
previously needed when constructing PreFlightFailure from
auth_result.controller_type, since that field is now already typed
as ControllerTypeKey.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(formatting): restore .2f precision in format_duration

The previous commit inadvertently changed format_duration from .2f to
.1f precision, dropping the second decimal place (e.g., "3.25s" became
"3.3s"). This restores the original .2f format specifier so durations
display with two decimal places, which is more useful for distinguishing
test timings at sub-second granularity.

The docstring examples are updated to reflect the correct output format.

* test(formatting): update format_duration assertions for .2f precision

Updates three test expectations to match the restored .2f format
specifier in format_duration:
  - "1.0s"  -> "1.00s"
  - "45.2s" -> "45.20s"
  - "30.0s" -> "30.00s"

* feat(formatting): add format_file_timestamp_ms() utility function

Three call sites (subprocess_runner, archive_aggregator, base_test)
were each independently doing:

    datetime.now().strftime(FILE_TIMESTAMP_MS_FORMAT)[:-3]

This is a leaky abstraction — callers had to know about the [:-3]
slice that trims microseconds (6 digits) to milliseconds (3 digits),
and each had to import both datetime and the format constant.

The new format_file_timestamp_ms() function encapsulates this pattern
in a single place, consistent with the existing format_timestamp_ms()
function that already handles the display-oriented timestamp format.

The _MICROSECOND_TRIM constant is reused by both functions.

* test(formatting): add tests for format_file_timestamp_ms

Adds TestFormatFileTimestampMs class with four tests covering:
- Known datetime produces exact expected string ("20250627_182616_834")
- Microsecond-to-millisecond trimming works correctly (123456 -> "123")
- None argument defaults to current time (boundary check)
- Output format has the expected 19-character length

These mirror the existing TestFormatTimestampMs structure to maintain
consistency in how we test the two timestamp formatting functions.

* refactor(subprocess-runner): use format_file_timestamp_ms utility

Replaces the inline datetime.now().strftime(FILE_TIMESTAMP_MS_FORMAT)[:-3]
pattern with the new format_file_timestamp_ms() utility. This removes the
need for subprocess_runner to know about the microsecond trimming detail
and drops the now-unused datetime and FILE_TIMESTAMP_MS_FORMAT imports.

* refactor(archive-aggregator): use format_file_timestamp_ms utility

Replaces the inline datetime.now().strftime(FILE_TIMESTAMP_MS_FORMAT)[:-3]
pattern with the new format_file_timestamp_ms() utility. Drops the
now-unused datetime and FILE_TIMESTAMP_MS_FORMAT imports.

This is the second of three call sites migrated to the shared utility.

* refactor(base-test): use format_file_timestamp_ms utility

Replaces the inline strftime + [:-3] slice with format_file_timestamp_ms()
in _generate_test_id(). Removes the now-unused FILE_TIMESTAMP_MS_FORMAT
import from core.constants (datetime import is retained — it is still
used in two other places in this module).

This is the last of three call sites migrated to the shared utility,
completing the elimination of the leaky [:-3] abstraction.

* feat(error-classification): add extract_http_status_code() function

Adds a public function to extract the HTTP status code from an
exception message as an integer, returning None if no code is found.

This reuses the existing _HTTP_STATUS_CODE_PATTERN regex that
_classify_auth_error already uses internally, so the extraction logic
is consistent. The function is intentionally separate from
_classify_auth_error (rather than changing it to return a 3-tuple)
to avoid breaking the 11 existing call sites and their test assertions.

This will be used by the pre-flight auth check to propagate structured
status codes into AuthCheckResult and PreFlightFailure, enabling
reliable is_403 detection without fragile substring matching.

* feat(types): add status_code field to PreFlightFailure

Adds an optional status_code: int | None field to the PreFlightFailure
frozen dataclass. This carries the structured HTTP status code from the
auth check through to the reporting layer, enabling reliable detection
of specific HTTP conditions (e.g., 403 Forbidden) without resorting to
fragile substring matching on the detail string.

The field defaults to None, which is correct for non-HTTP failures like
connection timeouts or DNS resolution errors where there is no HTTP
status code to report.

* feat(auth): add status_code field to AuthCheckResult

Mirrors the status_code field just added to PreFlightFailure. This
allows the pre-flight auth check to capture the HTTP status code at
the point of failure and pass it through to the reporting layer via
PreFlightFailure.

The field defaults to None, which is correct for both successful auth
checks (no error) and non-HTTP failures (timeouts, DNS, etc.).

* feat(auth): extract and propagate HTTP status_code in preflight check

Imports extract_http_status_code and calls it in the exception handler
of preflight_auth_check() to capture the HTTP status code from the
failed auth request. The extracted code is passed into AuthCheckResult
via the new status_code field.

This is the wiring that connects extract_http_status_code (added to
error_classification.py) with the AuthCheckResult field (added in the
previous commit). For non-HTTP errors (timeouts, DNS, etc.),
extract_http_status_code returns None, which is the correct default.

* fix(reporting): replace is_403 substring matching with structured check

The previous is_403 detection used fragile substring matching:

    is_403 = (
        str(HTTP_FORBIDDEN_CODE) in failure.detail
        or "Forbidden" in failure.detail
    )

This could false-positive on any detail string containing "403" or
"Forbidden" as a substring (e.g., "tried 403 times" or a URL path
containing "Forbidden"). It also required the detail string format
to remain stable.

Now that PreFlightFailure carries a structured status_code field,
we can use a direct integer comparison instead:

    is_403 = failure.status_code == HTTP_FORBIDDEN_CODE

This is unambiguous, type-safe, and decoupled from the detail string.

* feat(cli): pass status_code from AuthCheckResult to PreFlightFailure

Threads the status_code field through the CLI main function where
AuthCheckResult is converted into PreFlightFailure. This completes the
data flow from the pre-flight auth check through to the report
generator, where it replaces the fragile substring-based is_403 check.

* test(auth): add status_code propagation tests for preflight check

Adds two tests to TestPreflightAuthCheck:

1. test_propagates_http_status_code — verifies that when auth fails
   with "HTTP 403: Forbidden", the result carries status_code=403.
   This proves the extract_http_status_code -> AuthCheckResult wiring
   works end-to-end.

2. test_status_code_none_for_non_http_errors — verifies that when auth
   fails with a non-HTTP error like "Connection timed out", the
   status_code is None (not some spurious number extracted from the
   error message).

Also adds type: ignore[arg-type] to test_handles_unknown_controller_type
which deliberately passes an invalid controller type string to verify
graceful handling. The ignore is needed because preflight_auth_check now
expects ControllerTypeKey (a Literal type), and "UNKNOWN_CONTROLLER" is
intentionally outside that set — that's the entire point of the test.

* test(error-classification): add tests for extract_http_status_code

Adds TestExtractHttpStatusCode class with five tests covering:
- 401 extraction from "HTTP 401: Unauthorized"
- 403 extraction from "HTTP 403: Forbidden"
- 500 extraction from "HTTP 500: Internal Server Error"
- None returned for non-HTTP messages ("Connection timed out")
- None returned for generic messages ("Something went wrong")

These validate the public extract_http_status_code() function that was
added to error_classification.py, ensuring it correctly reuses the
_HTTP_STATUS_CODE_PATTERN regex to extract status codes and returns
None when no HTTP status code is present.

* test(combined-generator): add status_code to PreFlightFailure fixtures

Updates five PreFlightFailure constructors in the combined generator
tests to include the new status_code field, matching the structured
data that the production code now provides:

- Four auth failure tests: status_code=401 (matching "HTTP 401" detail)
- One 403 test: status_code=403 (matching "HTTP 403: Forbidden" detail)
- Unreachable test: unchanged (no status_code, defaults to None)

This ensures the test fixtures accurately reflect the data shape that
flows through the reporting pipeline in production, where the is_403
check now uses failure.status_code == HTTP_FORBIDDEN_CODE rather than
substring matching on the detail string.

* refactor(reporting): narrow _CURL_TEMPLATES key type to ControllerTypeKey

Changes the _CURL_TEMPLATES dict from dict[str, _CurlTemplate] to
dict[ControllerTypeKey, _CurlTemplate], and narrows the controller_type
parameter of _get_curl_example from str to ControllerTypeKey.

Since _CURL_TEMPLATES keys are always valid controller type literals
("ACI", "SDWAN", "CC"), the type annotation should reflect this.
Using ControllerTypeKey catches typos at type-check time and makes the
data flow consistent with the rest of the reporting pipeline, where
PreFlightFailure.controller_type is already ControllerTypeKey.

* refactor(controller): narrow detect_controller_type return to ControllerTypeKey

Changes the return type of detect_controller_type() from str to
ControllerTypeKey, which is a Literal type covering all supported
controller identifiers ("ACI", "SDWAN", "CC", "MERAKI", etc.).

This eliminates the need for callers to cast() the return value,
since the function now guarantees at the type level that it returns
a valid controller type key.

A cast(ControllerTypeKey, ...) is needed at the return point because
complete_sets is typed as list[str] (populated from CONTROLLER_REGISTRY
dict iteration). The keys are always valid ControllerTypeKey values,
but mypy can't infer this from dict key iteration. The cast documents
this boundary explicitly.

* refactor(cli): remove cast() now that detect_controller_type returns ControllerTypeKey

Now that detect_controller_type() declares its return type as
ControllerTypeKey (the previous commit), the cast() wrapper in main()
is redundant. Removing it also drops the now-unused cast and
ControllerTypeKey imports.

Before: controller_type = cast(ControllerTypeKey, detect_controller_type())
After:  controller_type = detect_controller_type()

The type narrowing is now handled at the source (controller.py) rather
than at every call site — one cast instead of N.

* test(auth): add type: ignore for parametrized controller_type argument

The pytest @parametrize decorator infers controller_type as str, but
preflight_auth_check now expects ControllerTypeKey (a Literal type).
The values ("ACI", "SDWAN", "CC") are all valid ControllerTypeKey
members, but mypy cannot narrow str to Literal through parametrize.

Adding type: ignore[arg-type] is the standard approach for this
pytest + Literal type interaction. The runtime behavior is unchanged.

* feat(banners): add _wrap_url_lines helper for banner content wrapping

Long controller URLs (e.g., https://sandboxdnacultramdeupURL.cisco.com)
can exceed the fixed 78-character banner content width, causing text to
overflow past the right border and breaking the visual box.

This adds a _wrap_url_lines() helper that keeps the URL on the same line
when the combined prefix + URL fits within BANNER_CONTENT_WIDTH, and
wraps the URL to an indented second line when it would overflow. This
approach preserves the fixed-width box (which is important for
consistent terminal rendering across 80-column terminals) while ensuring
all content stays within the borders.

The helper accepts an optional indent parameter for lines that are
already indented (e.g., "  curl -k <url>") so that both the prefix
and wrapped URL line receive consistent leading whitespace.

* fix(banners): wrap long URLs in auth failure banner

The "Could not authenticate to {display_name} at {url}" line is
constructed at runtime, and with long controller URLs (common in
enterprise environments like Catalyst Center sandboxes), the combined
string exceeds the 78-character banner width, causing the right border
to shift out of alignment.

This replaces the single f-string with a _wrap_url_lines() call that
keeps the URL inline for short URLs and wraps it to a second indented
line for long ones. Short URLs (e.g., https://apic.local) render
identically to before — no visual change for the common case.

* fix(banners): wrap long URLs in unreachable banner

The unreachable banner has two lines that embed the controller URL:
the "Could not connect to..." message and the "curl -k <url>" command.
Both can overflow the 78-character banner width with long URLs.

This applies _wrap_url_lines() to both lines. The curl command uses the
indent="  " parameter so both the "curl -k" prefix and the wrapped URL
receive the 2-space indent that visually groups them under the
"Verify the controller is reachable..." instruction line.

The ping command is not wrapped because it uses the extracted hostname
(not the full URL), which is always short enough to fit.

* test(banners): add unit tests for _wrap_url_lines helper

Tests the four key behaviors of the URL wrapping helper:

1. Short URLs that fit within BANNER_CONTENT_WIDTH stay on one line,
   preserving the original single-line format for the common case.

2. Long URLs that exceed the width are wrapped to a second line with
   2-space indentation, keeping the prefix on its own line.

3. The indent parameter is applied to both lines when wrapping occurs,
   so indented commands like "  curl -k" produce "  curl -k" / "    <url>"
   with the URL indented 2 more spaces relative to the prefix.

4. The indent parameter is applied to the single line when no wrapping
   is needed, maintaining consistent indentation.

Each test explicitly verifies its precondition (e.g., that the test URL
actually exceeds the width) to prevent tests from silently passing if
BANNER_CONTENT_WIDTH changes in the future.

* test(banners): add end-to-end tests for long URL banner rendering

These tests verify the actual rendered banner output — not just the
helper function — to ensure no line overflows the box border when a
long controller URL is used. This catches regressions that unit tests
on _wrap_url_lines alone would miss, such as a new banner line being
added that embeds the URL without using the wrapping helper.

Each test uses the same long URL from the original bug report
(https://sandboxdnacultramdeupURL.cisco.com) and asserts that every
rendered line is at most BANNER_CONTENT_WIDTH + 2 characters (the
content width plus the two border characters).

Both display_unreachable_banner and display_auth_failure_banner are
covered since they have different content layouts but the same overflow
risk.

* fix(cli): skip controller detection and preflight auth in dry-run mode

Dry-run mode doesn't need controller access, same as render-only.
Gate both detect_controller_type() and preflight_auth_check() behind
`not render_only and not dry_run` in main() and CombinedOrchestrator.

* fix(tests): mock preflight auth check in integration tests

The integration tests use bogus ACI credentials (ACI_URL=foo) which now
trigger the preflight auth check added in the previous commit. Since
these tests validate Robot rendering/execution/ordering behavior (not
authentication), mock both detect_controller_type and preflight_auth_check
in the existing setup_bogus_controller_env autouse fixtures.

* fix(tests): fix auth cache lock file test for filelock >= 3.13

filelock 3.24.2 (UnixFileLock) auto-deletes .lock files on release,
so the lock file does not persist between separate FileLock acquisitions.
The test incorrectly asserted .lock file existence after _cache_auth_data()
returned — this was testing filelock library behavior, not our code.

Updated to verify the end state: no cache or lock artifacts remain after
invalidation, regardless of filelock's cleanup behavior.

* refactor: move preflight auth check from CLI to CombinedOrchestrator

The nac-test CLI should remain a generic testing engine (Jinja + pabot)
that isn't tied to infrastructure controllers. This moves controller
detection and preflight auth validation from main.py into
CombinedOrchestrator.run_tests(), gated by has_pyats and not
render_only/dry_run.

Changes:
- Remove controller detection and auth check block from cli/main.py
- Add preflight auth check to CombinedOrchestrator.run_tests()
- Consolidate controller detection into run_tests() flow
- Add shared integration test conftest with setup_bogus_controller_env
- Use pytest.mark.usefixtures for selective fixture application
- Remove redundant auth mocks from unit tests (CombinedOrchestrator
  is fully mocked via @patch)
- Update test assertions for new orchestrator-level auth handling

* fix: skip preflight auth check in dry-run mode

Dry-run mode validates test structure, not execution — it should not
require controller credentials. The auth gate in run_tests() now
checks `not self.dry_run` alongside `not self.render_only`.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api API/controller authentication and communication code-quality Code quality improvements and standards enforcement enhancement New feature or request new-infra Issues related to the new pyats/robot infra currently under development pyats PyATS framework related

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Authentication Failure Handling Strategy: Fail Fast vs. Retry Per Test

4 participants