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
Co-authored-by: Brent Edwards <brent.edwards@cleverthis.com> Co-committed-by: Brent Edwards <brent.edwards@cleverthis.com>
224 lines
7.8 KiB
Python
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}"
|
|
)
|