mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(audit): migrate audit_log.details to JSONB so .astext works
Task 13's has_recent_tracing_gap query used .astext on a generic JSON column, which raises AttributeError at runtime. The choreographer's exception handler swallowed it, leaving the strike-count reset permanently inert in production. Migrate the column to JSONB (which supports .astext + GIN indexing for future audit queries), update the ORM, and add a real-DB integration test that would have caught this.
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
"""Real-DB integration test for ``AuditService.has_recent_tracing_gap``.
|
||||
|
||||
Why this test exists
|
||||
--------------------
|
||||
Task 13 (commit 887d073) added the PM-respawn rule-following retry
|
||||
detection. The query at ``roboco/services/audit.py:489`` filters
|
||||
``details->>'reason' == 'tracing_gap'`` via SQLAlchemy's ``.astext``
|
||||
accessor — but ``.astext`` only exists on ``JSONB.Comparator``, NOT on
|
||||
the generic ``JSON.Comparator``. The ORM declared the column as ``JSON``,
|
||||
so the access raised ``AttributeError`` at query construction time and
|
||||
the choreographer's exception handler swallowed it.
|
||||
|
||||
The six existing unit tests in
|
||||
``tests/unit/runtime/test_pm_respawn_reset.py`` all mocked
|
||||
``audit.has_recent_tracing_gap`` directly, never exercising the SQL —
|
||||
that's why they passed despite the production code being inert.
|
||||
|
||||
This test issues the real query against a real Postgres backend (the
|
||||
session-scoped test DB from ``tests/conftest.py``). It seeds an audit
|
||||
row with the production shape and asserts the True / False outcomes.
|
||||
With the ORM correctly declaring ``JSONB``, this passes; with the prior
|
||||
``JSON`` declaration it would raise the same ``AttributeError`` the
|
||||
production code raises.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from roboco.db import base as roboco_db_base
|
||||
from roboco.db.tables import AgentTable
|
||||
from roboco.models.base import AgentRole, AgentStatus
|
||||
from roboco.services.audit import AuditService
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
async def _seed_agent(session: AsyncSession) -> UUID:
|
||||
"""Insert one minimal AgentTable row and return its id.
|
||||
|
||||
``audit_log.agent_id`` has a SET NULL FK to ``agents.id``. Without an
|
||||
actual agent row the persist silently fails (best-effort) and the
|
||||
test would assert against zero rows for the wrong reason.
|
||||
"""
|
||||
agent = AgentTable(
|
||||
id=uuid4(),
|
||||
name="Audit Test Agent",
|
||||
slug=f"audit-test-{uuid4().hex[:8]}",
|
||||
role=AgentRole.CELL_PM,
|
||||
team=None,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="audit test",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
session.add(agent)
|
||||
await session.commit()
|
||||
return UUID(str(agent.id))
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def patched_session_factory(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> AsyncIterator[AsyncSession]:
|
||||
"""Point ``get_session_factory`` at the test-DB engine for the run.
|
||||
|
||||
``AuditService.log_event`` and ``has_recent_tracing_gap`` both open
|
||||
their own sessions via ``roboco.db.base.get_session_factory()`` —
|
||||
which without intervention binds to the production database URL
|
||||
from ``settings``. We hijack the factory to bind to the same engine
|
||||
the test fixture is using so the inserts and SELECT see the same
|
||||
rows.
|
||||
|
||||
Yields ``db_session`` so callers can also seed FK targets directly.
|
||||
"""
|
||||
test_engine = db_session.bind
|
||||
test_factory = async_sessionmaker(
|
||||
bind=test_engine, expire_on_commit=False, autoflush=False
|
||||
)
|
||||
monkeypatch.setattr(roboco_db_base, "get_session_factory", lambda: test_factory)
|
||||
yield db_session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_recent_tracing_gap_finds_seeded_row(
|
||||
patched_session_factory: AsyncSession,
|
||||
) -> None:
|
||||
"""A seeded gateway.rejected row with reason=tracing_gap is detected.
|
||||
|
||||
Exercises the real SQL path including ``details->>'reason'``. With
|
||||
the ORM declaring ``JSON`` (the bug), this raises AttributeError at
|
||||
query construction. With ``JSONB`` (the fix), it executes and
|
||||
returns True.
|
||||
"""
|
||||
agent_id = await _seed_agent(patched_session_factory)
|
||||
audit = AuditService()
|
||||
task_id = uuid4()
|
||||
since = datetime.now(UTC) - timedelta(seconds=60)
|
||||
|
||||
await audit.log_event(
|
||||
event_type="gateway.rejected",
|
||||
agent_id=agent_id,
|
||||
task_id=task_id,
|
||||
details={"verb": "delegate", "reason": "tracing_gap", "missing": ["plan"]},
|
||||
)
|
||||
|
||||
result = await audit.has_recent_tracing_gap(
|
||||
agent_id=agent_id,
|
||||
task_id=task_id,
|
||||
since=since,
|
||||
)
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_recent_tracing_gap_returns_false_for_other_task(
|
||||
patched_session_factory: AsyncSession,
|
||||
) -> None:
|
||||
"""Same agent, different task id — must NOT match."""
|
||||
agent_id = await _seed_agent(patched_session_factory)
|
||||
audit = AuditService()
|
||||
seeded_task_id = uuid4()
|
||||
other_task_id = uuid4()
|
||||
since = datetime.now(UTC) - timedelta(seconds=60)
|
||||
|
||||
await audit.log_event(
|
||||
event_type="gateway.rejected",
|
||||
agent_id=agent_id,
|
||||
task_id=seeded_task_id,
|
||||
details={"reason": "tracing_gap"},
|
||||
)
|
||||
|
||||
result = await audit.has_recent_tracing_gap(
|
||||
agent_id=agent_id,
|
||||
task_id=other_task_id,
|
||||
since=since,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_recent_tracing_gap_returns_false_for_other_reason(
|
||||
patched_session_factory: AsyncSession,
|
||||
) -> None:
|
||||
"""Reason other than 'tracing_gap' on the same (agent, task) — no match.
|
||||
|
||||
This is the most critical assertion: it proves the JSONB
|
||||
``details->>'reason' == 'tracing_gap'`` predicate is actually
|
||||
evaluated by Postgres, not silently dropped because ``.astext``
|
||||
raised before the SQL was ever issued.
|
||||
"""
|
||||
agent_id = await _seed_agent(patched_session_factory)
|
||||
audit = AuditService()
|
||||
task_id = uuid4()
|
||||
since = datetime.now(UTC) - timedelta(seconds=60)
|
||||
|
||||
await audit.log_event(
|
||||
event_type="gateway.rejected",
|
||||
agent_id=agent_id,
|
||||
task_id=task_id,
|
||||
details={"reason": "permission_denied"},
|
||||
)
|
||||
|
||||
result = await audit.has_recent_tracing_gap(
|
||||
agent_id=agent_id,
|
||||
task_id=task_id,
|
||||
since=since,
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_has_recent_tracing_gap_respects_since_window(
|
||||
patched_session_factory: AsyncSession,
|
||||
) -> None:
|
||||
"""Rows older than ``since`` must not match.
|
||||
|
||||
``log_event`` always stamps `timestamp = now()`. We pass a `since`
|
||||
one hour in the future to force every existing row to fall outside
|
||||
the window.
|
||||
"""
|
||||
agent_id = await _seed_agent(patched_session_factory)
|
||||
audit = AuditService()
|
||||
task_id = uuid4()
|
||||
future_since = datetime.now(UTC) + timedelta(hours=1)
|
||||
|
||||
await audit.log_event(
|
||||
event_type="gateway.rejected",
|
||||
agent_id=agent_id,
|
||||
task_id=task_id,
|
||||
details={"reason": "tracing_gap"},
|
||||
)
|
||||
|
||||
result = await audit.has_recent_tracing_gap(
|
||||
agent_id=agent_id,
|
||||
task_id=task_id,
|
||||
since=future_since,
|
||||
)
|
||||
assert result is False
|
||||
Reference in New Issue
Block a user