"""Step definitions for security exception handling tests.""" from __future__ import annotations from behave import given, then, when from behave.runner import Context from cleveragents.core.error_handling import ( ErrorCategory, classify_error, format_error_for_cli, redact_error_details, redact_value, wrap_unexpected, ) from cleveragents.core.exceptions import ( AuthenticationError, AuthorizationError, BusinessRuleViolation, CleverAgentsError, ConfigurationError, DatabaseError, ExecutionError, ExternalServiceError, FileSystemError, MissingConfigurationError, ModelNotAvailableError, NetworkError, PlanError, ProviderError, RateLimitError, ResourceConflictError, ResourceNotFoundError, StreamRoutingError, TokenLimitExceededError, ValidationError, ) __all__: list[str] = [] # --------------------------------------------------------------------------- # Background # --------------------------------------------------------------------------- @given("a security exceptions test environment") def step_given_sec_exc_env(context: Context) -> None: context.sec_exc = None # Exception context.sec_error_info = None # ErrorInfo | None context.sec_details = {} # dict context.sec_redacted = {} # dict context.sec_wrapped = None # CleverAgentsError | None context.sec_cli_output = "" context.sec_wrap_context = {} # dict context.sec_string_value = "" context.sec_redacted_string = "" # --------------------------------------------------------------------------- # Given — exception types # --------------------------------------------------------------------------- @given('a ValidationError with message "{msg}"') def step_given_validation_error(context: Context, msg: str) -> None: context.sec_exc = ValidationError(msg) @given('a ResourceNotFoundError for resource "{rtype}" with id "{rid}"') def step_given_not_found(context: Context, rtype: str, rid: str) -> None: context.sec_exc = ResourceNotFoundError(resource_type=rtype, resource_id=rid) @given('an AuthenticationError with message "{msg}"') def step_given_auth_error(context: Context, msg: str) -> None: context.sec_exc = AuthenticationError(msg) @given('an AuthorizationError with message "{msg}"') def step_given_authz_error(context: Context, msg: str) -> None: context.sec_exc = AuthorizationError(msg) @given('a RateLimitError with message "{msg}"') def step_given_rate_limit(context: Context, msg: str) -> None: context.sec_exc = RateLimitError(msg) @given('a DatabaseError with message "{msg}"') def step_given_db_error(context: Context, msg: str) -> None: context.sec_exc = DatabaseError(msg) @given('a ConfigurationError with message "{msg}"') def step_given_config_error(context: Context, msg: str) -> None: context.sec_exc = ConfigurationError(msg) @given('a NetworkError with message "{msg}"') def step_given_network_error(context: Context, msg: str) -> None: context.sec_exc = NetworkError(msg) @given('a FileSystemError with message "{msg}"') def step_given_fs_error(context: Context, msg: str) -> None: context.sec_exc = FileSystemError(msg) @given('a ProviderError with message "{msg}"') def step_given_provider_error(context: Context, msg: str) -> None: context.sec_exc = ProviderError(msg) @given('a TokenLimitExceededError with message "{msg}"') def step_given_token_limit(context: Context, msg: str) -> None: context.sec_exc = TokenLimitExceededError(msg) @given('a BusinessRuleViolation with message "{msg}"') def step_given_biz_rule(context: Context, msg: str) -> None: context.sec_exc = BusinessRuleViolation(msg) @given('a PlanError with message "{msg}"') def step_given_plan_error(context: Context, msg: str) -> None: context.sec_exc = PlanError(msg) @given('a bare Exception with message "{msg}"') def step_given_bare_exception(context: Context, msg: str) -> None: context.sec_exc = Exception(msg) @given('an ExecutionError with message "{msg}"') def step_given_execution_error(context: Context, msg: str) -> None: context.sec_exc = ExecutionError(msg) @given('a StreamRoutingError with message "{msg}"') def step_given_stream_error(context: Context, msg: str) -> None: context.sec_exc = StreamRoutingError(msg) @given('a ModelNotAvailableError with message "{msg}"') def step_given_model_unavail(context: Context, msg: str) -> None: context.sec_exc = ModelNotAvailableError(msg) @given('a ResourceConflictError with message "{msg}"') def step_given_conflict(context: Context, msg: str) -> None: context.sec_exc = ResourceConflictError(msg) @given('a MissingConfigurationError with message "{msg}"') def step_given_missing_config(context: Context, msg: str) -> None: context.sec_exc = MissingConfigurationError(msg) @given('an ExternalServiceError with message "{msg}"') def step_given_ext_svc_error(context: Context, msg: str) -> None: context.sec_exc = ExternalServiceError(msg) @given('a CleverAgentsError with message "{msg}"') def step_given_ca_error(context: Context, msg: str) -> None: context.sec_exc = CleverAgentsError(msg) @given('a detailed CleverAgentsError "{msg}" with detail "{k}" as "{v}"') def step_given_ca_error_details(context: Context, msg: str, k: str, v: str) -> None: context.sec_exc = CleverAgentsError(msg, details={k: v}) # --------------------------------------------------------------------------- # Given — error details for redaction # --------------------------------------------------------------------------- @given('redaction test details with key "{key}" and value "{value}"') def step_given_error_details(context: Context, key: str, value: str) -> None: context.sec_details[key] = value @given( 'redaction test details with nested key "{outer}" containing secret_key "{value}"' ) def step_given_nested_details(context: Context, outer: str, value: str) -> None: context.sec_details[outer] = {"secret_key": value} @given('a redaction test string value "{value}"') def step_given_string_value(context: Context, value: str) -> None: context.sec_string_value = value # --------------------------------------------------------------------------- # Given — wrapping context # --------------------------------------------------------------------------- @given('a wrapping context with key "{key}" and value "{value}"') def step_given_wrap_context(context: Context, key: str, value: str) -> None: context.sec_wrap_context[key] = value # --------------------------------------------------------------------------- # When steps # --------------------------------------------------------------------------- @when("I classify the security exception") def step_when_classify(context: Context) -> None: context.sec_error_info = classify_error(context.sec_exc) @when("I redact the error details") def step_when_redact_details(context: Context) -> None: context.sec_redacted = redact_error_details(context.sec_details) @when("I redact the string value") def step_when_redact_string(context: Context) -> None: context.sec_redacted_string = redact_value(context.sec_string_value) @when("I wrap the unexpected exception") def step_when_wrap(context: Context) -> None: ctx = context.sec_wrap_context or None context.sec_wrapped = wrap_unexpected(context.sec_exc, context=ctx) @when('I wrap the unexpected exception with message "{msg}"') def step_when_wrap_msg(context: Context, msg: str) -> None: context.sec_wrapped = wrap_unexpected(context.sec_exc, safe_message=msg) @when("I format the error for CLI") def step_when_format_cli(context: Context) -> None: assert context.sec_error_info is not None context.sec_cli_output = format_error_for_cli(context.sec_error_info) # --------------------------------------------------------------------------- # Then steps — classification # --------------------------------------------------------------------------- @then("the error code should be {code}") def step_then_error_code(context: Context, code: str) -> None: assert context.sec_error_info is not None assert context.sec_error_info.code.value == int(code), ( f"Expected {code}, got {context.sec_error_info.code.value}" ) @then('the error code name should be "{name}"') def step_then_error_code_name(context: Context, name: str) -> None: assert context.sec_error_info is not None assert context.sec_error_info.code.name == name, ( f"Expected {name}, got {context.sec_error_info.code.name}" ) @then('the error handling category should be "{cat}"') def step_then_error_category(context: Context, cat: str) -> None: assert context.sec_error_info is not None expected = ErrorCategory.CLIENT if cat == "CLIENT" else ErrorCategory.SERVER assert context.sec_error_info.category == expected # --------------------------------------------------------------------------- # Then steps — redaction # --------------------------------------------------------------------------- @then('the redacted detail "{key}" should be "{expected}"') def step_then_redacted_detail(context: Context, key: str, expected: str) -> None: assert context.sec_redacted[key] == expected, ( f"Expected {expected!r}, got {context.sec_redacted[key]!r}" ) @then('the redacted detail "{key}" should contain "{text}"') def step_then_redacted_contains(context: Context, key: str, text: str) -> None: val = str(context.sec_redacted[key]) assert text in val, f"Expected '{text}' in {val!r}" @then('the redacted detail "{key}" should not contain "{text}"') def step_then_redacted_not_contains(context: Context, key: str, text: str) -> None: val = str(context.sec_redacted[key]) assert text not in val, f"Did not expect '{text}' in {val!r}" @then('the redacted nested detail "{outer}" key "{inner}" should be "{expected}"') def step_then_nested_redacted( context: Context, outer: str, inner: str, expected: str ) -> None: nested = context.sec_redacted[outer] assert nested[inner] == expected @then('the redacted string should contain "{text}"') def step_then_redacted_string_contains(context: Context, text: str) -> None: assert text in context.sec_redacted_string # --------------------------------------------------------------------------- # Then steps — wrapping # --------------------------------------------------------------------------- @then("the wrapped error should be a CleverAgentsError") def step_then_wrapped_is_ca(context: Context) -> None: assert isinstance(context.sec_wrapped, CleverAgentsError) @then('the wrapped error message should be "{msg}"') def step_then_wrapped_msg(context: Context, msg: str) -> None: assert context.sec_wrapped is not None assert context.sec_wrapped.message == msg, ( f"Expected {msg!r}, got {context.sec_wrapped.message!r}" ) @then('the wrapped error details should have key "{key}"') def step_then_wrapped_details_key(context: Context, key: str) -> None: assert context.sec_wrapped is not None assert key in context.sec_wrapped.details, ( f"Key '{key}' not in {list(context.sec_wrapped.details.keys())}" ) @then('the wrapped error details key "{key}" should be "{expected}"') def step_then_wrapped_detail_value(context: Context, key: str, expected: str) -> None: assert context.sec_wrapped is not None assert context.sec_wrapped.details[key] == expected # --------------------------------------------------------------------------- # Then steps — CLI formatting # --------------------------------------------------------------------------- @then('the error CLI output should contain "{text}"') def step_then_cli_contains(context: Context, text: str) -> None: assert text in context.sec_cli_output, ( f"Expected '{text}' in:\n{context.sec_cli_output}" ) @then('the error CLI output should not contain "{text}"') def step_then_cli_not_contains(context: Context, text: str) -> None: assert text not in context.sec_cli_output, ( f"Did not expect '{text}' in:\n{context.sec_cli_output}" ) # --------------------------------------------------------------------------- # Given steps — list / integer / boolean details # --------------------------------------------------------------------------- @given('redaction test details with key "{key}" and list values "{v1}" and "{v2}"') def step_given_list_details(context: Context, key: str, v1: str, v2: str) -> None: context.sec_details[key] = [v1, v2] @given('redaction test details with key "{key}" and integer value {val:d}') def step_given_int_details(context: Context, key: str, val: int) -> None: context.sec_details[key] = val @given('redaction test details with key "{key}" and boolean value {val}') def step_given_bool_details(context: Context, key: str, val: str) -> None: context.sec_details[key] = val.lower() == "true" # --------------------------------------------------------------------------- # Given steps — tracked details for non-mutation test (B1) # --------------------------------------------------------------------------- @given('a tracked CleverAgentsError "{msg}" with details key "{k}" value "{v}"') def step_given_ca_tracked(context: Context, msg: str, k: str, v: str) -> None: context.sec_exc = CleverAgentsError(msg, details={k: v}) # Capture a snapshot of the original details *before* wrapping. context.sec_original_details_ref = dict(context.sec_exc.details) # --------------------------------------------------------------------------- # Then steps — error info message assertions (S1) # --------------------------------------------------------------------------- @then('the error info message should contain "{text}"') def step_then_info_msg_contains(context: Context, text: str) -> None: assert context.sec_error_info is not None assert text in context.sec_error_info.message, ( f"Expected '{text}' in message: {context.sec_error_info.message!r}" ) @then('the error info message should not contain "{text}"') def step_then_info_msg_not_contains(context: Context, text: str) -> None: assert context.sec_error_info is not None assert text not in context.sec_error_info.message, ( f"Did not expect '{text}' in message: {context.sec_error_info.message!r}" ) # --------------------------------------------------------------------------- # Then steps — list redaction (B2/T1) # --------------------------------------------------------------------------- @then('the redacted detail "{key}" should be a list') def step_then_is_list(context: Context, key: str) -> None: assert isinstance(context.sec_redacted[key], list), ( f"Expected list, got {type(context.sec_redacted[key]).__name__}" ) @then('the redacted list "{key}" should contain "{text}"') def step_then_list_contains(context: Context, key: str, text: str) -> None: values = [str(v) for v in context.sec_redacted[key]] assert any(text in v for v in values), f"Expected '{text}' in one of: {values}" @then('the redacted list "{key}" should not contain "{text}"') def step_then_list_not_contains(context: Context, key: str, text: str) -> None: values = [str(v) for v in context.sec_redacted[key]] assert all(text not in v for v in values), ( f"Did not expect '{text}' in any of: {values}" ) # --------------------------------------------------------------------------- # Then steps — non-string passthrough (T4) # --------------------------------------------------------------------------- @then('the redacted detail "{key}" should be integer {val:d}') def step_then_int_detail(context: Context, key: str, val: int) -> None: assert context.sec_redacted[key] == val, ( f"Expected {val}, got {context.sec_redacted[key]!r}" ) @then('the redacted detail "{key}" should be boolean {val}') def step_then_bool_detail(context: Context, key: str, val: str) -> None: expected = val.lower() == "true" assert context.sec_redacted[key] is expected, ( f"Expected {expected}, got {context.sec_redacted[key]!r}" ) # --------------------------------------------------------------------------- # Then steps — non-mutation (B1) # --------------------------------------------------------------------------- @then('the original exception details reference should not have key "{key}"') def step_then_orig_ref_no_key(context: Context, key: str) -> None: assert key not in context.sec_original_details_ref, ( f"Key '{key}' unexpectedly found in original snapshot: " f"{list(context.sec_original_details_ref.keys())}" )