Files
cleveragents-core/features/steps/audit_wiring_extended_steps.py
T
brent.edwards e3d3c7926d
CI / integration_tests (push) Has been cancelled
CI / typecheck (push) Has been cancelled
CI / build (push) Has been cancelled
CI / benchmark-publish (push) Has been cancelled
CI / e2e_tests (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / quality (push) Has been cancelled
CI / status-check (push) Has been cancelled
CI / security (push) Has been cancelled
CI / coverage (push) Has been cancelled
CI / unit_tests (push) Has been cancelled
CI / helm (push) Has been cancelled
CI / benchmark-regression (push) Has been cancelled
CI / docker (push) Has been cancelled
feat(events): add user_identity field to DomainEvent and propagate through event pipeline (#1257)
Co-authored-by: Brent Edwards <brent.edwards@cleverthis.com>
Co-committed-by: Brent Edwards <brent.edwards@cleverthis.com>
2026-04-02 16:59:07 +00:00

224 lines
7.8 KiB
Python

"""Extended step definitions for AuditService EventBus wiring (Forgejo #581).
Covers additional coverage and security scenarios:
- COV-6: Subscriber recovery after transient failure
- COV-5: Multiple non-security event type filtering
- TFLAW-3: Nested sensitive data redaction
- TFLAW-4: Auto-generated correlation_id propagation
- SEC-1: User identity extraction from details
Split from ``audit_service_wiring_steps.py`` to stay within the
500-line file limit (CONTRIBUTING.md).
"""
from __future__ import annotations
from typing import cast
from behave import given, then, when
from behave.runner import Context
from cleveragents.application.services.audit_event_subscriber import (
AuditEventSubscriber,
)
from cleveragents.application.services.audit_service import AuditService
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.reactive import ReactiveEventBus
from cleveragents.infrastructure.events.types import EventType
from features.mocks.transient_fail_audit_service import TransientFailAuditService
from features.steps.audit_service_wiring_steps import _fresh_audit_service
__all__: list[str] = []
# ── COV-6: Subscriber recovery after failure ─────────────────────
@given("an event bus with a transiently failing audit service")
def step_event_bus_with_transient_failure(context: Context) -> None:
context.audit_service = _fresh_audit_service()
context.event_bus = ReactiveEventBus()
context.fail_once_service = TransientFailAuditService(context.audit_service)
context.subscriber = AuditEventSubscriber(
audit_service=cast(AuditService, context.fail_once_service),
event_bus=context.event_bus,
)
@when("a plan_applied event is emitted causing a transient failure")
def step_emit_causing_transient_failure(context: Context) -> None:
context.event_bus.emit(
DomainEvent(
event_type=EventType.PLAN_APPLIED,
plan_id="PLAN-TRANSIENT",
details={"action_name": "local/fail-once"},
)
)
@when("a plan_cancelled event is emitted after the transient failure")
def step_emit_after_transient(context: Context) -> None:
context.event_bus.emit(
DomainEvent(
event_type=EventType.PLAN_CANCELLED,
plan_id="PLAN-RECOVER",
details={"reason": "recovery test"},
)
)
@then("the audit log should contain the plan_cancelled entry after recovery")
def step_audit_has_cancelled_after_recovery(context: Context) -> None:
entries = context.audit_service.list_entries(event_type="plan_cancelled")
assert len(entries) >= 1, (
f"Expected plan_cancelled entry after recovery, got {len(entries)}"
)
# ── COV-5: Multiple non-security event type filtering ────────────
@when("non-security events PLAN_CREATED and ACTOR_INVOKED are emitted")
def step_emit_multiple_non_security(context: Context) -> None:
context.event_bus.emit(
DomainEvent(event_type=EventType.PLAN_CREATED, plan_id="P-ns1")
)
context.event_bus.emit(
DomainEvent(event_type=EventType.ACTOR_INVOKED, details={"actor": "test"})
)
@then("the audit log should remain empty")
def step_audit_log_empty(context: Context) -> None:
total = context.audit_service.count()
assert total == 0, f"Expected 0 audit entries, got {total}"
# ── TFLAW-3: Nested sensitive data redaction ─────────────────────
@when("an event with nested sensitive details is emitted")
def step_emit_nested_sensitive(context: Context) -> None:
context.event_bus.emit(
DomainEvent(
event_type=EventType.CONFIG_CHANGED,
details={
"config": {
"api_key": "sk-proj-NESTED1234567890",
"database": {"password": "s3cret!"},
},
"normal_field": "visible",
},
)
)
@then("the nested audit log values should be redacted")
def step_nested_redacted(context: Context) -> None:
entries = context.audit_service.list_entries(event_type="config_changed")
assert len(entries) >= 1, "Expected config_changed entry"
details = entries[0].details
config = details.get("config", {})
assert config.get("api_key") == "***REDACTED***", (
f"Expected nested api_key redacted, got {config.get('api_key')!r}"
)
db = config.get("database", {})
assert db.get("password") == "***REDACTED***", (
f"Expected nested password redacted, got {db.get('password')!r}"
)
assert details.get("normal_field") == "visible"
# ── TFLAW-4: Auto-generated correlation_id propagation ───────────
@when("a plan_applied event without explicit correlation_id is emitted")
def step_emit_auto_correlation(context: Context) -> None:
context.event_bus.emit(
DomainEvent(
event_type=EventType.PLAN_APPLIED,
plan_id="PLAN-AUTO-CORR",
details={"action_name": "local/auto-corr"},
)
)
@then("the audit entry details should contain a non-empty correlation_id")
def step_audit_has_auto_correlation_id(context: Context) -> None:
entries = context.audit_service.list_entries(event_type="plan_applied")
assert len(entries) >= 1, "No plan_applied entries found"
details = entries[0].details
cid = details.get("correlation_id")
assert cid is not None and len(str(cid)) > 0, (
f"Expected non-empty auto-generated correlation_id, got {cid!r}"
)
# ── SEC-1: User identity extraction from details ─────────────────
@when("a session_created event with user_identity in details is emitted")
def step_emit_session_with_user_identity(context: Context) -> None:
context.event_bus.emit(
DomainEvent(
event_type=EventType.SESSION_CREATED,
session_id="SES-UI",
details={
"mode": "interactive",
"user_identity": "test-user@example.com",
},
)
)
@then('the audit entry should have user_identity "{expected_uid}"')
def step_audit_entry_user_identity(context: Context, expected_uid: str) -> None:
entries = context.audit_service.list_entries(event_type="session_created")
assert len(entries) >= 1, "No session_created entries found"
assert entries[0].user_identity == expected_uid, (
f"Expected user_identity={expected_uid!r}, got {entries[0].user_identity!r}"
)
# ── #715: DomainEvent.user_identity field propagation ────────────
@when('a session_created event with user_identity field "{uid}" is emitted')
def step_emit_session_with_user_identity_field(context: Context, uid: str) -> None:
context.event_bus.emit(
DomainEvent(
event_type=EventType.SESSION_CREATED,
session_id="SES-FIELD",
user_identity=uid,
details={"mode": "interactive"},
)
)
@when(
'a session_created event with both user_identity field "{field_uid}" '
'and details identity "{details_uid}" is emitted'
)
def step_emit_session_with_both_identities(
context: Context, field_uid: str, details_uid: str
) -> None:
context.event_bus.emit(
DomainEvent(
event_type=EventType.SESSION_CREATED,
session_id="SES-BOTH",
user_identity=field_uid,
details={
"mode": "interactive",
"user_identity": details_uid,
},
)
)
@then("the audit entry user_identity should be None")
def step_audit_entry_user_identity_none(context: Context) -> None:
entries = context.audit_service.list_entries(event_type="plan_applied")
assert len(entries) >= 1, "No plan_applied entries found"
assert entries[0].user_identity is None, (
f"Expected user_identity=None, got {entries[0].user_identity!r}"
)