"""Step definitions for SEC7 - Audit logging for apply. Tests the audit service: recording, querying, filtering, retention, pruning, and data class serialization. """ from __future__ import annotations from datetime import UTC, datetime, timedelta from behave import given, then, when from behave.runner import Context from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from cleveragents.application.services.audit_service import ( _TIMESTAMP_FMT, AuditService, ) from cleveragents.config.settings import Settings from cleveragents.infrastructure.database.models import AuditLogModel, Base # ── Helpers ─────────────────────────────────────────────────────── def _make_settings(**overrides: object) -> Settings: """Create a Settings instance with optional field overrides.""" Settings._instance = None base = Settings() if overrides: return base.model_copy(update=overrides) return base def _fresh_service(settings: Settings | None = None) -> AuditService: """Create an AuditService backed by an in-memory SQLite database.""" if settings is None: settings = _make_settings(database_url="sqlite:///:memory:", audit_async=False) engine = create_engine("sqlite:///:memory:", echo=False) Base.metadata.create_all(engine) session = sessionmaker(bind=engine)() return AuditService(settings=settings, session=session) def _insert_old_entry( service: AuditService, days_ago: int, event_type: str = "plan_applied", ) -> None: """Insert an entry with a manually backdated created_at. Uses the same timestamp format as the service (``_TIMESTAMP_FMT``) so that lexicographic comparisons in ``prune()`` and ``since`` filtering remain correct. Directly accesses ``service._session`` because there is no public API for inserting entries with an arbitrary ``created_at``. """ old_time = (datetime.now(tz=UTC) - timedelta(days=days_ago)).strftime( _TIMESTAMP_FMT ) row = AuditLogModel( event_type=event_type, details="{}", created_at=old_time, ) service._session.add(row) service._session.commit() # ── Given ───────────────────────────────────────────────────────── @given("a fresh audit service") def step_fresh_service(context: Context) -> None: context.service = _fresh_service() context.entry = None context.entries = [] context.prune_result = None @given("a fresh audit service with mixed events") def step_fresh_with_mixed(context: Context) -> None: context.service = _fresh_service() svc = context.service svc.record(event_type="plan_applied", plan_id="plan-A", project_name="project-X") svc.record(event_type="plan_applied", plan_id="plan-A", project_name="project-Y") svc.record(event_type="plan_cancelled", plan_id="plan-B", project_name="project-X") svc.record(event_type="config_changed", project_name="project-Z") context.entry = None context.entries = [] @given("a fresh audit service with old and new events") def step_fresh_with_old_new(context: Context) -> None: context.service = _fresh_service() _insert_old_entry(context.service, days_ago=100) context.service.record(event_type="plan_applied", plan_id="new") context.entry = None context.entries = [] @given("a fresh audit service with {count:d} events") def step_fresh_with_count(context: Context, count: int) -> None: context.service = _fresh_service() for i in range(count): context.service.record(event_type="plan_applied", plan_id=f"plan-{i}") context.entry = None context.entries = [] @given("a fresh audit service with entries from {days:d} days ago") def step_fresh_with_old(context: Context, days: int) -> None: context.service = _fresh_service() _insert_old_entry(context.service, days_ago=days) context.entry = None context.entries = [] context.prune_result = None @given("a fresh audit service with {count:d} entries from {days:d} days ago") def step_fresh_count_old(context: Context, count: int, days: int) -> None: context.service = _fresh_service() for _ in range(count): _insert_old_entry(context.service, days_ago=days) context.entry = None context.entries = [] context.prune_result = None @given("default settings") def step_default_settings(context: Context) -> None: context.settings = _make_settings() @given("settings with audit_retention_days {days:d}") def step_settings_retention(context: Context, days: int) -> None: context.settings = _make_settings(audit_retention_days=days) # ── When ────────────────────────────────────────────────────────── @when( 'I record a "{event_type}" event with plan_id "{plan_id}" and project "{project}"' ) def step_record_plan_project( context: Context, event_type: str, plan_id: str, project: str ) -> None: context.entry = context.service.record( event_type=event_type, plan_id=plan_id, project_name=project ) @when( 'I record a "{event_type}" event with plan_id "{plan_id}" ' 'and details reason "{reason}"' ) def step_record_with_reason( context: Context, event_type: str, plan_id: str, reason: str ) -> None: context.entry = context.service.record( event_type=event_type, plan_id=plan_id, details={"reason": reason}, ) @when( 'I record a "{event_type}" event with details resource_id ' '"{resource_id}" and tool "{tool}"' ) def step_record_resource_modified( context: Context, event_type: str, resource_id: str, tool: str ) -> None: context.entry = context.service.record( event_type=event_type, details={"resource_id": resource_id, "tool": tool}, ) @when('I record a "{event_type}" event with actor "{actor}"') def step_record_with_actor(context: Context, event_type: str, actor: str) -> None: context.entry = context.service.record(event_type=event_type, actor_name=actor) @when('I record a "{event_type}" event with user "{user}"') def step_record_with_user(context: Context, event_type: str, user: str) -> None: context.entry = context.service.record(event_type=event_type, user_identity=user) @when( 'I record a "{event_type}" event with details entity_type ' '"{entity_type}" and entity_name "{entity_name}"' ) def step_record_entity_deleted( context: Context, event_type: str, entity_type: str, entity_name: str ) -> None: context.entry = context.service.record( event_type=event_type, details={"entity_type": entity_type, "entity_name": entity_name}, ) @when('I record a "{event_type}" event with no details') def step_record_no_details(context: Context, event_type: str) -> None: context.entry = context.service.record(event_type=event_type) @when('I record {count:d} "{event_type}" events') def step_record_multiple(context: Context, count: int, event_type: str) -> None: for i in range(count): context.entry = context.service.record( event_type=event_type, plan_id=f"plan-{i}" ) @when('I list entries with plan filter "{plan_id}"') def step_list_by_plan(context: Context, plan_id: str) -> None: context.entries = context.service.list_entries(plan_id=plan_id) @when('I list entries with project filter "{project}"') def step_list_by_project(context: Context, project: str) -> None: context.entries = context.service.list_entries(project_name=project) @when('I list entries with type filter "{event_type}"') def step_list_by_type(context: Context, event_type: str) -> None: context.entries = context.service.list_entries(event_type=event_type) @when('I list entries since "{since}"') def step_list_since(context: Context, since: str) -> None: context.entries = context.service.list_entries(since=since) @when("I list entries with limit {limit:d}") def step_list_with_limit(context: Context, limit: int) -> None: context.entries = context.service.list_entries(limit=limit) @when("I list all entries") def step_list_all(context: Context) -> None: context.entries = context.service.list_entries() @when("I fetch the entry by its ID") def step_fetch_by_id(context: Context) -> None: context.fetched = context.service.get_entry(context.entry.id) @when("I fetch entry with ID {audit_id:d}") def step_fetch_nonexistent(context: Context, audit_id: int) -> None: context.fetched = context.service.get_entry(audit_id) @when("I prune with retention {days:d} days") def step_prune(context: Context, days: int) -> None: context.prune_result = context.service.prune(retention_days=days) @when("I serialize the entry to dict") def step_serialize(context: Context) -> None: context.entry_dict = context.entry.as_dict() @when('I record a "{event_type}" event with all nullable fields as None') def step_record_all_none(context: Context, event_type: str) -> None: context.entry = context.service.record(event_type=event_type) @when('I record a "{event_type}" event with datetime in details') def step_record_datetime_details(context: Context, event_type: str) -> None: context.entry = context.service.record( event_type=event_type, details={"timestamp": datetime.now(tz=UTC)}, ) @when( 'I record a "{event_type}" event with details files_changed ' "{count:d} and validation_passed true" ) def step_record_structured_details( context: Context, event_type: str, count: int ) -> None: context.entry = context.service.record( event_type=event_type, details={"files_changed": count, "validation_passed": True}, ) @when('I record a "{event_type}" event with session_id "{session_id}"') def step_record_session_created( context: Context, event_type: str, session_id: str ) -> None: context.entry = context.service.record( event_type=event_type, details={"session_id": session_id}, ) @when('I record an event with invalid event_type "{event_type}"') def step_record_invalid_type(context: Context, event_type: str) -> None: try: context.service.record(event_type=event_type) context.error = None except ValueError as exc: context.error = exc @when("I create an AuditService with invalid settings") def step_create_invalid(context: Context) -> None: try: AuditService(settings="not-settings") # type: ignore[arg-type] context.error = None except TypeError as exc: context.error = exc # ── Then ────────────────────────────────────────────────────────── @then("the audit log should contain {count:d} entry") @then("the audit log should contain {count:d} entries") def step_check_count(context: Context, count: int) -> None: assert context.service.count() == count, ( f"Expected {count}, got {context.service.count()}" ) @then('the entry event_type should be "{expected}"') def step_check_event_type(context: Context, expected: str) -> None: assert context.entry.event_type == expected @then('the entry plan_id should be "{expected}"') def step_check_plan_id(context: Context, expected: str) -> None: assert context.entry.plan_id == expected @then('the entry project_name should be "{expected}"') def step_check_project(context: Context, expected: str) -> None: assert context.entry.project_name == expected @then('the entry actor_name should be "{expected}"') def step_check_actor(context: Context, expected: str) -> None: assert context.entry.actor_name == expected @then('the entry user_identity should be "{expected}"') def step_check_user(context: Context, expected: str) -> None: assert context.entry.user_identity == expected @then('the entry details should contain key "{key}"') def step_check_details_key(context: Context, key: str) -> None: assert key in context.entry.details, f"Key {key!r} not in {context.entry.details}" @then('the entry details key "{key}" should be "{expected}"') def step_check_details_value(context: Context, key: str, expected: str) -> None: assert str(context.entry.details[key]) == expected @then("the entry details should be empty") def step_check_details_empty(context: Context) -> None: assert context.entry.details == {} or context.entry.details is None @then('all returned entries should have plan_id "{expected}"') def step_all_plan_id(context: Context, expected: str) -> None: assert len(context.entries) > 0 for e in context.entries: assert e.plan_id == expected, f"Entry {e.id} has plan_id={e.plan_id}" @then('all returned entries should have project_name "{expected}"') def step_all_project(context: Context, expected: str) -> None: assert len(context.entries) > 0 for e in context.entries: assert e.project_name == expected @then('all returned entries should have event_type "{expected}"') def step_all_event_type(context: Context, expected: str) -> None: assert len(context.entries) > 0 for e in context.entries: assert e.event_type == expected @then("no entries should be returned") def step_no_entries(context: Context) -> None: assert len(context.entries) == 0 @then("exactly {count:d} entries should be returned") def step_exact_count(context: Context, count: int) -> None: assert len(context.entries) == count, ( f"Expected {count}, got {len(context.entries)}" ) @then('the first entry should have plan_id "{expected}"') def step_first_plan_id(context: Context, expected: str) -> None: actual = context.entries[0].plan_id assert actual == expected, ( f"Expected first entry plan_id={expected!r}, got {actual!r} " f"(created_at={context.entries[0].created_at!r})" ) @then('the fetched entry should have event_type "{expected}"') def step_fetched_event_type(context: Context, expected: str) -> None: assert context.fetched is not None assert context.fetched.event_type == expected @then('the fetched entry should have plan_id "{expected}"') def step_fetched_plan_id(context: Context, expected: str) -> None: assert context.fetched is not None assert context.fetched.plan_id == expected @then("no entry should be returned") def step_no_entry(context: Context) -> None: assert context.fetched is None @then("the count should be {expected:d}") def step_count_is(context: Context, expected: int) -> None: assert context.service.count() == expected @then("the audit log should be empty") def step_log_empty(context: Context) -> None: assert context.service.count() == 0 @then("the audit log should not be empty") def step_log_not_empty(context: Context) -> None: assert context.service.count() > 0 @then("the prune result should be {expected:d}") def step_prune_result(context: Context, expected: int) -> None: assert context.prune_result == expected @then("audit_retention_days should be {expected:d}") def step_retention_days(context: Context, expected: int) -> None: assert context.settings.audit_retention_days == expected @then('the dict should contain keys "{k1}" "{k2}" "{k3}" "{k4}" "{k5}" "{k6}"') def step_dict_keys( context: Context, k1: str, k2: str, k3: str, k4: str, k5: str, k6: str ) -> None: d = context.entry_dict for key in (k1, k2, k3, k4, k5, k6): assert key in d, f"Key {key!r} not in dict" @then('the dict details key "{key}" should be {expected:d}') def step_dict_details_int(context: Context, key: str, expected: int) -> None: assert context.entry_dict["details"][key] == expected @then("the entry plan_id should be None") def step_plan_id_none(context: Context) -> None: assert context.entry.plan_id is None @then("the entry project_name should be None") def step_project_none(context: Context) -> None: assert context.entry.project_name is None @then("the entry actor_name should be None") def step_actor_none(context: Context) -> None: assert context.entry.actor_name is None @then("the entry user_identity should be None") def step_user_none(context: Context) -> None: assert context.entry.user_identity is None @then("a TypeError should be raised for audit service") def step_type_error(context: Context) -> None: assert isinstance(context.error, TypeError) @then("a ValueError should be raised for audit service") def step_value_error(context: Context) -> None: assert isinstance(context.error, ValueError) @then('the audit error message should contain "{text}"') def step_audit_error_message_contains(context: Context, text: str) -> None: assert text in str(context.error), f"Expected {text!r} in {context.error!s}" # ── Context manager / owned session steps ───────────────────── @given("a fresh audit service with owned session") def step_fresh_owned_session(context: Context) -> None: settings = _make_settings(database_url="sqlite:///:memory:", audit_async=False) context.service = AuditService(settings=settings) context.entry = None context.entries = [] @when('I use the service as a context manager to record "{event_type}"') def step_context_manager_record(context: Context, event_type: str) -> None: with context.service as svc: context.entry = svc.record(event_type=event_type) @then('the audit context manager result should have event_type "{expected}"') def step_context_mgr_event_type(context: Context, expected: str) -> None: assert context.entry.event_type == expected @when("I close the service") def step_close_service(context: Context) -> None: context.error = None try: context.service.close() except Exception as exc: context.error = exc @then("no audit error should be raised") def step_no_audit_error(context: Context) -> None: assert context.error is None @given("a fresh audit service with a corrupt details row") def step_fresh_corrupt_details(context: Context) -> None: context.service = _fresh_service() row = AuditLogModel( event_type="plan_applied", details="NOT-VALID-JSON{{{", created_at=datetime.now(tz=UTC).strftime(_TIMESTAMP_FMT), ) context.service._session.add(row) context.service._session.commit() context.entry = None context.entries = [] @then("the first listed entry details should be empty") def step_first_listed_empty_details(context: Context) -> None: assert context.entries[0].details == {} # ── CLI command steps ───────────────────────────────────────── def _get_cli_runner_and_app(context): """Set up a CliRunner and patch _get_audit_service.""" from unittest.mock import patch from typer.testing import CliRunner from cleveragents.cli.commands.audit import app as audit_app runner = CliRunner() patcher = patch( "cleveragents.cli.commands.audit._get_audit_service", return_value=context.service, ) patcher.start() if not hasattr(context, "_cleanup_handlers"): context._cleanup_handlers = [] context._cleanup_handlers.append(patcher.stop) return runner, audit_app def _run_audit_cli(context, args): """Run an audit CLI command and set both cli_result and output.""" runner, app = _get_cli_runner_and_app(context) result = runner.invoke(app, args) context.cli_result = result context.output = result.output return result @when("I invoke the audit CLI list command") def step_cli_list(context: Context) -> None: _run_audit_cli(context, ["list"]) @when( 'I invoke the audit CLI list command with plan "{plan}" ' 'and type "{etype}" and project "{project}" and limit {limit:d}' ) def step_cli_list_filters( context: Context, plan: str, etype: str, project: str, limit: int ) -> None: _run_audit_cli( context, [ "list", "--plan", plan, "--type", etype, "--project", project, "--limit", str(limit), ], ) @when("I invoke the audit CLI show command with the entry ID") def step_cli_show(context: Context) -> None: _run_audit_cli(context, ["show", str(context.entry.id)]) @when("I invoke the audit CLI show command with ID {audit_id:d}") def step_cli_show_nonexistent(context: Context, audit_id: int) -> None: _run_audit_cli(context, ["show", str(audit_id)]) @when("I invoke the audit CLI count command") def step_cli_count(context: Context) -> None: _run_audit_cli(context, ["count"]) @when("I invoke the audit CLI prune command with days {days:d} and yes flag") def step_cli_prune_yes(context: Context, days: int) -> None: _run_audit_cli(context, ["prune", "--days", str(days), "--yes"]) @when("I invoke the audit CLI prune command with days {days:d} and no confirmation") def step_cli_prune_no_confirm(context: Context, days: int) -> None: runner, app = _get_cli_runner_and_app(context) result = runner.invoke(app, ["prune", "--days", str(days)], input="n\n") context.cli_result = result context.output = result.output # NOTE: "the CLI exit code should be {code:d}" reused from garbage_collection_cli_steps.py # NOTE: 'the CLI output should contain "{text}"' reused from cli_steps.py # ── Logging configuration steps ─────────────────────────────── @when('I configure structlog for development with level "{level}"') def step_configure_structlog_dev(context: Context, level: str) -> None: from cleveragents.config.logging import configure_structlog context.error = None try: configure_structlog(env="development", log_level=level) context.structlog_configured = True except Exception as exc: context.error = exc context.structlog_configured = False @when('I configure structlog for production with level "{level}"') def step_configure_structlog_prod(context: Context, level: str) -> None: from cleveragents.config.logging import configure_structlog context.error = None try: configure_structlog(env="production", log_level=level) context.structlog_configured = True except Exception as exc: context.error = exc context.structlog_configured = False @when('I configure structlog with invalid level "{level}"') def step_configure_structlog_invalid(context: Context, level: str) -> None: from cleveragents.config.logging import configure_structlog try: configure_structlog(log_level=level) context.error = None except ValueError as exc: context.error = exc @when('I call get_logger with name "{name}"') def step_get_logger(context: Context, name: str) -> None: from cleveragents.config.logging import get_logger context.logger = get_logger(name) @then("structlog should be configured") def step_structlog_configured(context: Context) -> None: assert context.structlog_configured is True @then("a structlog logger should be returned") def step_structlog_logger_returned(context: Context) -> None: assert context.logger is not None # ── Redaction edge case steps ───────────────────────────────── @when("I call set_show_secrets with a non-bool value") def step_set_show_secrets_non_bool(context: Context) -> None: from cleveragents.shared.redaction import set_show_secrets try: set_show_secrets("yes") # type: ignore[arg-type] context.error = None except TypeError as exc: context.error = exc @when("I check if empty string is sensitive") def step_is_sensitive_empty(context: Context) -> None: from cleveragents.shared.redaction import is_sensitive_key context.result = is_sensitive_key("") @then("the sensitivity result should be false") def step_sensitivity_result_false(context: Context) -> None: assert context.result is False @when("I mask an empty database URL") def step_mask_empty_url(context: Context) -> None: from cleveragents.shared.redaction import mask_database_url context.masked_url = mask_database_url("") @then("the audit masked URL should be empty") def step_audit_masked_url_empty(context: Context) -> None: assert context.masked_url == "" @when("I register an empty secret pattern") def step_register_empty_pattern(context: Context) -> None: from cleveragents.shared.redaction import register_pattern try: register_pattern("") context.error = None except ValueError as exc: context.error = exc @when("I run the structlog processor with show_secrets enabled") def step_processor_show_secrets(context: Context) -> None: from cleveragents.shared.redaction import ( secrets_masking_processor, set_show_secrets, ) set_show_secrets(True) try: event = {"event": "test", "api_key": "sk-secret123"} context.processor_result = secrets_masking_processor(None, "info", event) finally: set_show_secrets(False) @then("the event dict should be returned unchanged") def step_event_unchanged(context: Context) -> None: assert context.processor_result["api_key"] == "sk-secret123" @when("I run the structlog processor with a nested dict value") def step_processor_nested_dict(context: Context) -> None: from cleveragents.shared.redaction import secrets_masking_processor event = { "event": "test", "config": {"api_key": "sk-secret123", "name": "test"}, } context.processor_result = secrets_masking_processor(None, "info", event) @then("the nested dict should be redacted") def step_nested_dict_redacted(context: Context) -> None: config = context.processor_result["config"] assert config["api_key"] == "***REDACTED***" assert config["name"] == "test"