Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Test package for the OmniBioAI security audit service.

Developer: Manish Kumar <manish@omnibioai.org>
"""
2 changes: 2 additions & 0 deletions tests/_mysql_integration_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
already uses 33061). There is deliberately no
ALLOW_PRODUCTION_TESTS-style escape hatch -- there is no supported
mode where these tests intentionally operate against production.

Developer: Manish Kumar <manish@omnibioai.org>
"""
from __future__ import annotations

Expand Down
2 changes: 2 additions & 0 deletions tests/_redis_integration_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
different port. There is deliberately no escape hatch -- there is no
supported mode where these tests intentionally operate against the
shared production-adjacent instance.

Developer: Manish Kumar <manish@omnibioai.org>
"""
from __future__ import annotations

Expand Down
12 changes: 12 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,31 @@
"""Shared pytest fixtures for the security audit test suite: mocked async/sync Redis clients, an
AuditLogger and StreamReader built against those mocks, and isolated SQLite databases for the
audit_events table and the /audit/events route. Nothing here opens a real Redis or MySQL
connection.

Developer: Manish Kumar <manish@omnibioai.org>
"""

from unittest.mock import AsyncMock, MagicMock, patch

import pytest


@pytest.fixture
def mock_async_redis():
"""Provide an AsyncMock standing in for the async Redis client used by AuditLogger."""
return AsyncMock()


@pytest.fixture
def mock_sync_redis():
"""Provide a MagicMock standing in for the sync Redis client used by StreamReader."""
return MagicMock()


@pytest.fixture
def audit_logger(mock_async_redis):
"""Build an AuditLogger with Redis patched out, bound to the mocked async Redis client."""
with patch("audit.logger.redis") as mock_redis_module:
mock_redis_module.from_url.return_value = mock_async_redis
from audit.logger import AuditLogger
Expand All @@ -25,6 +36,7 @@ def audit_logger(mock_async_redis):

@pytest.fixture
def stream_reader(mock_sync_redis):
"""Build a StreamReader with Redis patched out, bound to the mocked sync Redis client."""
with patch("consumers.stream_reader.redis") as mock_redis_module:
mock_redis_module.from_url.return_value = mock_sync_redis
from consumers.stream_reader import StreamReader
Expand Down
35 changes: 35 additions & 0 deletions tests/test_audit_health_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
recording stub -- this checks WHICH conditions get wired with WHAT
metadata, independent of security_alerts.py's own dedup/sink behavior
(already covered by tests/test_security_alerts.py).

Developer: Manish Kumar <manish@omnibioai.org>
"""
from datetime import datetime

Expand All @@ -27,6 +29,8 @@
# ---------------------------------------------------------------------------

def test_redis_health_reports_zero_pending_cleanly(stream_reader):
"""Report Redis as available with zero pending, no oldest-pending age, and the consumer lag
sourced from xinfo_groups."""
reader, mock_redis = stream_reader
mock_redis.xlen.return_value = 0
mock_redis.xpending.return_value = {"pending": 0}
Expand All @@ -44,6 +48,8 @@ def test_redis_health_reports_zero_pending_cleanly(stream_reader):


def test_redis_health_reports_pending_backlog_and_oldest_age(stream_reader):
"""Report the pending count, oldest pending age, retry-in-progress count, and active/idle
consumer stats from a real backlog."""
reader, mock_redis = stream_reader
mock_redis.xlen.return_value = 42
mock_redis.xpending.return_value = {"pending": 3}
Expand All @@ -66,6 +72,8 @@ def test_redis_health_reports_pending_backlog_and_oldest_age(stream_reader):


def test_redis_health_degrades_gracefully_on_connection_failure(stream_reader):
"""Report Redis as unavailable with the error name, and never fabricate a pending count, when
the connection fails."""
reader, mock_redis = stream_reader
mock_redis.xlen.side_effect = ConnectionError("redis unreachable")

Expand Down Expand Up @@ -97,6 +105,8 @@ def test_redis_health_missing_xinfo_groups_support_is_not_a_failure(stream_reade
# ---------------------------------------------------------------------------

def test_persistence_health_with_empty_tables(db_session):
"""Report persistence as available with no last-success time and zero quarantine when both
tables are empty."""
health = get_persistence_pipeline_health(db_session)

assert health.available is True
Expand All @@ -106,6 +116,7 @@ def test_persistence_health_with_empty_tables(db_session):


def test_persistence_health_reports_last_success_and_quarantine_count(db_session):
"""Report the last successful persistence time and the quarantine count from seeded rows."""
db_session.add(AuditEventRecord(
event_id="evt-1", timestamp=datetime(2026, 1, 1, 12, 0, 0), # noqa: DTZ001 -- naive column, matches AuditEventRecord convention
service="auth", event_type="login", action="login",
Expand All @@ -129,6 +140,8 @@ def test_persistence_health_reports_last_success_and_quarantine_count(db_session


def test_persistence_health_degrades_gracefully_on_db_failure():
"""Report persistence as unavailable with the error set, and never fabricate a quarantine count,
when the database query fails."""
from unittest.mock import MagicMock

broken_db = MagicMock()
Expand All @@ -146,6 +159,7 @@ def test_persistence_health_degrades_gracefully_on_db_failure():
# ---------------------------------------------------------------------------

def test_get_pipeline_health_combines_both_sides(stream_reader, db_session, recording_alerts):
"""Combine Redis and persistence health under one timezone-aware generated_at timestamp."""
reader, mock_redis = stream_reader
mock_redis.xlen.return_value = 0
mock_redis.xpending.return_value = {"pending": 0}
Expand All @@ -165,6 +179,8 @@ def test_get_pipeline_health_combines_both_sides(stream_reader, db_session, reco
# ---------------------------------------------------------------------------

class _RecordedCall:
"""Capture the keyword arguments of one emit_security_alert call for assertion."""

def __init__(self, kwargs):
self.condition = kwargs.get("condition")
self.severity = kwargs.get("severity")
Expand All @@ -174,6 +190,7 @@ def __init__(self, kwargs):

@pytest.fixture
def recording_alerts(monkeypatch):
"""Replace emit_security_alert with a recorder so tests can assert on which alerts fired."""
calls = []

def _fake_emit(**kwargs):
Expand All @@ -184,10 +201,12 @@ def _fake_emit(**kwargs):


def _consumers(idle_ms=100):
"""Build a single-consumer xinfo_consumers-style response with the given idle time."""
return [{"name": "worker-1", "pending": 0, "idle": idle_ms}]


def test_redis_unavailable_fires_critical_alert(stream_reader, db_session, recording_alerts):
"""Fire a critical redis_stream_unavailable alert when the Redis connection fails."""
reader, mock_redis = stream_reader
mock_redis.xlen.side_effect = ConnectionError("redis unreachable")

Expand All @@ -201,6 +220,8 @@ def test_redis_unavailable_fires_critical_alert(stream_reader, db_session, recor


def test_no_consumer_ever_registered_fires_critical_alert(stream_reader, db_session, recording_alerts):
"""Fire a critical audit_worker_never_registered alert, but not a Redis-unavailable one, when no
consumer has ever registered."""
reader, mock_redis = stream_reader
mock_redis.xlen.return_value = 0
mock_redis.xpending.return_value = {"pending": 0}
Expand All @@ -215,6 +236,7 @@ def test_no_consumer_ever_registered_fires_critical_alert(stream_reader, db_sess


def test_healthy_redis_with_a_registered_consumer_fires_no_availability_alerts(stream_reader, db_session, recording_alerts):
"""Fire no availability alerts when Redis is healthy and a consumer is registered."""
reader, mock_redis = stream_reader
mock_redis.xlen.return_value = 0
mock_redis.xpending.return_value = {"pending": 0}
Expand All @@ -229,6 +251,8 @@ def test_healthy_redis_with_a_registered_consumer_fires_no_availability_alerts(s


def test_pel_pending_threshold_unconfigured_fires_nothing(stream_reader, db_session, recording_alerts, monkeypatch):
"""Fire no PEL backlog alert when the pending-count threshold is not configured, however large
the backlog."""
monkeypatch.delenv("AUDIT_ALERT_PEL_PENDING_THRESHOLD", raising=False)
reader, mock_redis = stream_reader
mock_redis.xlen.return_value = 100
Expand All @@ -244,6 +268,8 @@ def test_pel_pending_threshold_unconfigured_fires_nothing(stream_reader, db_sess


def test_pel_pending_threshold_configured_and_exceeded_fires_warning(stream_reader, db_session, recording_alerts, monkeypatch):
"""Fire a warning PEL backlog alert, carrying the pending count and threshold, once the
configured threshold is exceeded."""
monkeypatch.setenv("AUDIT_ALERT_PEL_PENDING_THRESHOLD", "10")
reader, mock_redis = stream_reader
mock_redis.xlen.return_value = 100
Expand All @@ -263,6 +289,7 @@ def test_pel_pending_threshold_configured_and_exceeded_fires_warning(stream_read


def test_pel_pending_threshold_configured_but_not_exceeded_fires_nothing(stream_reader, db_session, recording_alerts, monkeypatch):
"""Fire no PEL backlog alert while the pending count stays under the configured threshold."""
monkeypatch.setenv("AUDIT_ALERT_PEL_PENDING_THRESHOLD", "1000")
reader, mock_redis = stream_reader
mock_redis.xlen.return_value = 100
Expand All @@ -279,6 +306,8 @@ def test_pel_pending_threshold_configured_but_not_exceeded_fires_nothing(stream_


def test_pel_age_threshold_configured_and_exceeded_fires_warning(stream_reader, db_session, recording_alerts, monkeypatch):
"""Fire a warning alert carrying the oldest pending age once the configured age threshold is
exceeded."""
monkeypatch.setenv("AUDIT_ALERT_PEL_AGE_THRESHOLD_SECONDS", "60")
reader, mock_redis = stream_reader
mock_redis.xlen.return_value = 10
Expand All @@ -297,6 +326,8 @@ def test_pel_age_threshold_configured_and_exceeded_fires_warning(stream_reader,


def test_worker_stall_threshold_configured_and_exceeded_fires_warning(stream_reader, db_session, recording_alerts, monkeypatch):
"""Fire a warning alert carrying the least-idle consumer's idle time once the configured stall
threshold is exceeded."""
monkeypatch.setenv("AUDIT_ALERT_WORKER_STALL_THRESHOLD_MS", "5000")
reader, mock_redis = stream_reader
mock_redis.xlen.return_value = 0
Expand All @@ -312,6 +343,7 @@ def test_worker_stall_threshold_configured_and_exceeded_fires_warning(stream_rea


def test_audit_database_unavailable_fires_critical_alert(stream_reader, recording_alerts):
"""Fire a critical alert on the audit-persistence component when the database is unavailable."""
from unittest.mock import MagicMock

reader, mock_redis = stream_reader
Expand All @@ -331,6 +363,7 @@ def test_audit_database_unavailable_fires_critical_alert(stream_reader, recordin


def test_quarantine_count_threshold_unconfigured_fires_nothing(stream_reader, db_session, recording_alerts, monkeypatch):
"""Fire no quarantine-count alert when its threshold is not configured."""
monkeypatch.delenv("AUDIT_ALERT_QUARANTINE_COUNT_THRESHOLD", raising=False)
db_session.add(QuarantinedAuditEvent(stream_message_id="1-0", raw_data="{}", failure_category="malformed", delivery_attempts=5))
db_session.commit()
Expand All @@ -346,6 +379,8 @@ def test_quarantine_count_threshold_unconfigured_fires_nothing(stream_reader, db


def test_quarantine_count_threshold_configured_and_exceeded_fires_warning(stream_reader, db_session, recording_alerts, monkeypatch):
"""Fire a warning alert carrying the quarantine count and threshold once the configured
threshold is exceeded."""
monkeypatch.setenv("AUDIT_ALERT_QUARANTINE_COUNT_THRESHOLD", "1")
db_session.add(QuarantinedAuditEvent(stream_message_id="1-0", raw_data="{}", failure_category="malformed", delivery_attempts=5))
db_session.add(QuarantinedAuditEvent(stream_message_id="2-0", raw_data="{}", failure_category="malformed", delivery_attempts=5))
Expand Down
Loading
Loading