|
| 1 | +"""#90 — bounded grace on expires_at enforcement and lease-expiry audit logging (§14).""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import asyncio |
| 6 | +import contextlib |
| 7 | +import datetime as dt |
| 8 | +from typing import Any |
| 9 | + |
| 10 | +import pytest |
| 11 | + |
| 12 | +from arcp import ( |
| 13 | + Capabilities, |
| 14 | + ClientInfo, |
| 15 | + LeaseConstraints, |
| 16 | + LeaseExpiredError, |
| 17 | + RuntimeInfo, |
| 18 | + pair_memory_transports, |
| 19 | +) |
| 20 | +from arcp._messages.execution import LeaseConstraints as LC |
| 21 | +from arcp._runtime.lease import LeaseOpContext, validate_lease_op |
| 22 | +from arcp.client import ARCPClient |
| 23 | +from arcp.runtime import ARCPRuntime, StaticBearerVerifier |
| 24 | + |
| 25 | + |
| 26 | +def test_expiry_grace_window_is_applied_and_configurable() -> None: |
| 27 | + now = dt.datetime.now(dt.UTC) |
| 28 | + # expires_at 0.5s in the past relative to `now`. |
| 29 | + expires = (now - dt.timedelta(seconds=0.5)).isoformat().replace("+00:00", "Z") |
| 30 | + constraints = LC(expires_at=expires) |
| 31 | + lease = {"fs.read": ["*"]} |
| 32 | + ctx = LeaseOpContext(capability="fs.read", target="f", now=now) |
| 33 | + |
| 34 | + # Within a 1s grace window -> still authorized. |
| 35 | + validate_lease_op(lease, ctx, constraints=constraints, grace_sec=1.0) |
| 36 | + |
| 37 | + # Grace disabled -> expired. |
| 38 | + with pytest.raises(LeaseExpiredError): |
| 39 | + validate_lease_op(lease, ctx, constraints=constraints, grace_sec=0.0) |
| 40 | + |
| 41 | + |
| 42 | +class _CapturingLogger: |
| 43 | + def __init__(self) -> None: |
| 44 | + self.records: list[tuple[str, dict[str, Any]]] = [] |
| 45 | + |
| 46 | + def bind(self, **_kw: Any) -> _CapturingLogger: |
| 47 | + return self |
| 48 | + |
| 49 | + def info(self, event: str, **kw: Any) -> None: |
| 50 | + self.records.append((event, kw)) |
| 51 | + |
| 52 | + def warning(self, *_a: Any, **_k: Any) -> None: ... |
| 53 | + def error(self, *_a: Any, **_k: Any) -> None: ... |
| 54 | + def debug(self, *_a: Any, **_k: Any) -> None: ... |
| 55 | + def exception(self, *_a: Any, **_k: Any) -> None: ... |
| 56 | + |
| 57 | + |
| 58 | +async def test_lease_expiration_is_logged_for_audit() -> None: |
| 59 | + logger = _CapturingLogger() |
| 60 | + rt = ARCPRuntime( |
| 61 | + runtime=RuntimeInfo(name="r", version="1"), |
| 62 | + bearer=StaticBearerVerifier({"tok": "p1"}), |
| 63 | + heartbeat_interval_sec=None, |
| 64 | + lease_expiry_grace_sec=0.1, |
| 65 | + logger=logger, |
| 66 | + ) |
| 67 | + |
| 68 | + async def slow(input_value, ctx): |
| 69 | + await asyncio.sleep(5.0) |
| 70 | + return "never" |
| 71 | + |
| 72 | + rt.register_agent("slow", slow) |
| 73 | + |
| 74 | + server_t, client_t = pair_memory_transports() |
| 75 | + accept_task = asyncio.create_task(rt.accept(server_t)) |
| 76 | + client = ARCPClient( |
| 77 | + client=ClientInfo(name="c", version="1"), |
| 78 | + token="tok", |
| 79 | + capabilities=Capabilities(features=rt.capabilities.features), |
| 80 | + ) |
| 81 | + await client.connect(client_t) |
| 82 | + try: |
| 83 | + expiry = (dt.datetime.now(dt.UTC) + dt.timedelta(milliseconds=100)).isoformat() |
| 84 | + handle = await client.submit( |
| 85 | + agent="slow", |
| 86 | + lease_request={"fs.read": ["/tmp/*"]}, |
| 87 | + lease_constraints=LeaseConstraints(expires_at=expiry.replace("+00:00", "Z")), |
| 88 | + ) |
| 89 | + with pytest.raises(LeaseExpiredError): |
| 90 | + await asyncio.wait_for(handle.done, timeout=3.0) |
| 91 | + assert any(event == "lease_expired" for event, _ in logger.records), logger.records |
| 92 | + finally: |
| 93 | + with contextlib.suppress(Exception): |
| 94 | + await client.close() |
| 95 | + accept_task.cancel() |
| 96 | + with contextlib.suppress(asyncio.CancelledError, Exception): |
| 97 | + await accept_task |
| 98 | + await rt.close() |
0 commit comments