Files
roboco/tests/integration/test_notification_expiry_sweep.py
T
fc41dfa40e fix(security): active guard enforcement, CEO A2A target check, notification expiry (#595)
* fix(security): guard goes active; CEO A2A respects no-comms roles; ack notifications expire

ROBOCO_GUARD_PASSIVE_MODE defaults to false in both compose files — the
deferred post-calibration flip; fail_secure stays off and the env override
remains the rollback. can_a2a_direct no longer short-circuits the CEO past
the no-comms set (auditor/pr_reviewer/prompter/secretary), now canonical
in foundation.policy.communications.NO_COMMS_ROLES and shared with the
content-actions gate; the A2A service refuses at conversation creation
instead of silently suppressing the wake. Ack-required notifications get
expires_at stamped from ROBOCO_NOTIFICATION_ACK_TTL_HOURS (default 48,
0 disables), so the re-escalation sweeper's expires_at query matches rows
for the first time.

* refactor(notification): extract _ack_and_expiry — xenon rank back under B

The expires_at stamping pushed _create_notification_with_session to
rank C; the requires_ack + expiry derivation moves into a helper with
the same semantics and comments.

* test(conftest): dispose the global DB engine after every test

Production code reaching get_db_context()/get_engine() lazily creates the
process-global engine bound to the current event loop; with per-test
function-scoped loops, any later test touching the global path inherits a
dead-loop engine and dies with 'Future attached to a different loop' —
the order-dependent class that has been wandering the suite (cloud_auth
login, metrics, tasks-routes, full-lifecycle) whenever collection order
shifts. An autouse fixture now close_db()s after every test, keeping the
global path loop-local; no-op when untouched.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-19 18:46:44 +02:00

170 lines
5.8 KiB
Python

"""expires_at is now stamped at creation (NotificationService) and actually
matched by NotificationDeliveryService.sweep_expired_notifications' SQL
WHERE clause — before the fix the column was never written, so this query
always matched zero rows regardless of how stale a notification was.
Integration tests against the migrated Postgres DB: `sweep_expired_notifications`
issues a real `expires_at < now()` query, so a mocked session (as
`tests/unit/services/test_notification_delivery.py` uses) can't exercise it.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING, cast
from uuid import UUID, uuid4
import pytest
from roboco.config import settings
from roboco.db.tables import AgentTable, NotificationTable
from roboco.models import AgentRole, AgentStatus, NotificationPriority, NotificationType
from roboco.models.base import Team
from roboco.models.notification import CreateNotificationParams
from roboco.services.notification import NotificationService
from roboco.services.notification_delivery import get_notification_delivery_service
from sqlalchemy import select
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
async def _seed_agent(db: AsyncSession, *, role: AgentRole, slug: str) -> UUID:
agent = AgentTable(
id=uuid4(),
name=slug,
slug=slug,
role=role,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt=slug,
capabilities=[],
permissions={},
metrics={},
)
db.add(agent)
await db.flush()
return cast("UUID", agent.id)
@pytest.mark.asyncio
async def test_created_notification_expires_at_is_stamped_and_matched_by_sweep(
db_session: AsyncSession,
) -> None:
"""End-to-end: NotificationService._create_notification stamps expires_at
for an ack-required row, and once that deadline is in the past,
sweep_expired_notifications' real Postgres query finds it (count 1) —
the exact round trip that was a dead no-op before this fix, since
expires_at was always NULL and `expires_at < now()` never matched."""
unique = uuid4().hex[:8]
sender = await _seed_agent(
db_session, role=AgentRole.DEVELOPER, slug=f"sndr-{unique}"
)
recipient = await _seed_agent(
db_session, role=AgentRole.CELL_PM, slug=f"pm-{unique}"
)
svc = NotificationService()
await svc._create_notification(
CreateNotificationParams(
notification_type=NotificationType.BLOCKER_ESCALATION,
priority=NotificationPriority.HIGH,
from_agent=str(sender),
to_agents=[str(recipient)],
subject="blocked",
body="external dependency",
),
db_session=db_session,
)
await db_session.flush()
row = (
await db_session.execute(
select(NotificationTable).where(
NotificationTable.type == NotificationType.BLOCKER_ESCALATION,
NotificationTable.from_agent == sender,
)
)
).scalar_one()
assert row.expires_at is not None
assert row.requires_ack is True
# Backdate it past the deadline (no real clock wait) and confirm the
# sweep's `expires_at < now()` predicate now actually matches.
row.expires_at = datetime.now(UTC) - timedelta(minutes=1)
await db_session.flush()
deliv = get_notification_delivery_service(db_session)
count = await deliv.sweep_expired_notifications()
assert count >= 1
@pytest.mark.asyncio
async def test_directly_stamped_expired_row_is_matched_by_sweep_query(
db_session: AsyncSession,
) -> None:
"""Isolates the sweep query mechanics from creation: a hand-built
ack-required, unacked row with expires_at in the past must be counted."""
unique = uuid4().hex[:8]
sender = await _seed_agent(
db_session, role=AgentRole.DEVELOPER, slug=f"s2-{unique}"
)
recipient = await _seed_agent(db_session, role=AgentRole.QA, slug=f"r2-{unique}")
notification = NotificationTable(
type=NotificationType.ALERT,
priority=NotificationPriority.HIGH,
from_agent=sender,
to_agents=[recipient],
subject="stale alert",
body="body",
requires_ack=True,
expires_at=datetime.now(UTC) - timedelta(hours=1),
)
db_session.add(notification)
await db_session.flush()
deliv = get_notification_delivery_service(db_session)
count = await deliv.sweep_expired_notifications()
assert count >= 1
@pytest.mark.asyncio
async def test_zero_ttl_disables_expires_at_stamping_end_to_end(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""notification_ack_ttl_hours=0 leaves expires_at NULL even for an
ack-required notification created through the real service."""
monkeypatch.setattr(settings, "notification_ack_ttl_hours", 0)
unique = uuid4().hex[:8]
sender = await _seed_agent(
db_session, role=AgentRole.DEVELOPER, slug=f"s3-{unique}"
)
recipient = await _seed_agent(
db_session, role=AgentRole.CELL_PM, slug=f"pm3-{unique}"
)
svc = NotificationService()
await svc._create_notification(
CreateNotificationParams(
notification_type=NotificationType.BLOCKER_ESCALATION,
priority=NotificationPriority.HIGH,
from_agent=str(sender),
to_agents=[str(recipient)],
subject="blocked",
body="external dependency",
),
db_session=db_session,
)
await db_session.flush()
row = (
await db_session.execute(
select(NotificationTable).where(
NotificationTable.type == NotificationType.BLOCKER_ESCALATION,
NotificationTable.from_agent == sender,
)
)
).scalar_one()
assert row.expires_at is None