mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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>
This commit is contained in:
@@ -9,6 +9,7 @@ without spinning up a Postgres + Redis stack.
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -17,6 +18,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.config import settings
|
||||
from roboco.foundation.policy.communications import ACK_REQUIRED_BY_TYPE
|
||||
from roboco.models import NotificationPriority, NotificationType
|
||||
from roboco.models.notification import CreateNotificationParams
|
||||
@@ -414,6 +416,74 @@ async def test_create_notification_requires_ack_derives_from_type(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# expires_at stamping (notification_ack_ttl_hours) — feeds
|
||||
# NotificationDeliveryService.sweep_expired_notifications' re-escalation.
|
||||
# Column existed but was never written, so the sweep query always matched
|
||||
# zero rows.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ack_required_notification_gets_expires_at(
|
||||
svc: NotificationService,
|
||||
) -> None:
|
||||
"""An ack-required row (BLOCKER_ESCALATION) is stamped expires_at ~=
|
||||
now + notification_ack_ttl_hours."""
|
||||
aid = uuid4()
|
||||
db = _FakeDb(agent_uuid=aid)
|
||||
before = datetime.now(UTC)
|
||||
with _patch_db_context(db):
|
||||
await svc.send_blocker_notification(
|
||||
task_id="t1", blocker_reason="r", from_agent="system", to_pm="cell-pm"
|
||||
)
|
||||
after = datetime.now(UTC)
|
||||
rows = [r for r in db.added if r.type == NotificationType.BLOCKER_ESCALATION]
|
||||
assert rows
|
||||
expires_at = rows[0].expires_at
|
||||
assert expires_at is not None
|
||||
ttl = timedelta(hours=settings.notification_ack_ttl_hours)
|
||||
assert before + ttl <= expires_at <= after + ttl
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_informational_notification_gets_no_expires_at(
|
||||
svc: NotificationService,
|
||||
) -> None:
|
||||
"""A non-ack-required row (REVIEW_REQUEST) never gets a deadline — the
|
||||
sweep only ever re-escalates ack-required rows, so stamping one would be
|
||||
dead weight."""
|
||||
aid = uuid4()
|
||||
db = _FakeDb(agent_uuid=aid)
|
||||
with _patch_db_context(db):
|
||||
await svc.send_qa_ready_notification(
|
||||
task_id="t1", from_agent="be-dev-1", to_qa="be-qa"
|
||||
)
|
||||
rows = [r for r in db.added if r.type == NotificationType.REVIEW_REQUEST]
|
||||
assert rows
|
||||
assert rows[0].expires_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ack_required_notification_expires_at_disabled_by_zero_ttl(
|
||||
svc: NotificationService,
|
||||
) -> None:
|
||||
"""notification_ack_ttl_hours=0 disables stamping entirely (legacy: NULL,
|
||||
never expires) even for an ack-required type."""
|
||||
aid = uuid4()
|
||||
db = _FakeDb(agent_uuid=aid)
|
||||
with (
|
||||
patch("roboco.services.notification.settings.notification_ack_ttl_hours", 0),
|
||||
_patch_db_context(db),
|
||||
):
|
||||
await svc.send_blocker_notification(
|
||||
task_id="t1", blocker_reason="r", from_agent="system", to_pm="cell-pm"
|
||||
)
|
||||
rows = [r for r in db.added if r.type == NotificationType.BLOCKER_ESCALATION]
|
||||
assert rows
|
||||
assert rows[0].expires_at is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coordination-event producers (reassignment / collision / unblock /
|
||||
# dependency-revival / stale-claim-reaped)
|
||||
|
||||
Reference in New Issue
Block a user