|
22 | 22 | import base64 |
23 | 23 | import importlib.util |
24 | 24 | import json |
25 | | -import math |
26 | 25 | import time |
27 | 26 | from collections.abc import Iterator |
| 27 | +from datetime import UTC, datetime, timedelta |
28 | 28 | from pathlib import Path |
29 | 29 | from typing import Annotated, Any |
30 | 30 |
|
|
68 | 68 |
|
69 | 69 | _GRANT_KEY_B64 = base64.b64encode(bytes(range(32))).decode() |
70 | 70 | _SIGNING_KEY = b"attach-ticket-test-signing-key-1" |
71 | | -_IDENTITY = "vgi_rpc.Identity.v1" |
| 71 | +_IDENTITY = "vgi_rpc.Identity.v2" |
72 | 72 |
|
73 | 73 |
|
74 | 74 | @pytest.fixture(autouse=True) |
@@ -293,7 +293,7 @@ class _MintingWorker(TicketProbeWorker): |
293 | 293 | @classmethod |
294 | 294 | def mint_grant(cls, principal: str, purpose: str, scopes: list[str], ttl_seconds: int) -> IssuedGrant: |
295 | 295 | """Mint grant.""" |
296 | | - return IssuedGrant(token=f"own-{principal}", expires_at=time.time() + ttl_seconds) |
| 296 | + return IssuedGrant(token=f"own-{principal}", expires_at=datetime.now(UTC) + timedelta(seconds=ttl_seconds)) |
297 | 297 |
|
298 | 298 |
|
299 | 299 | def _server(worker: Any, *, transport: str = "http", configured: bool = True, keys: bool = True) -> Any: |
@@ -469,15 +469,15 @@ def test_option_names_are_case_insensitive(self) -> None: |
469 | 469 | [(0, 3600, 3600), (60, 3600, 60), (99999, 3600, 3600), (0, None, None), (60, None, 60)], |
470 | 470 | ) |
471 | 471 | def test_lifetime(self, ttl: int, ceiling: int | None, expected: int | None) -> None: |
472 | | - """expires_at = min(requested, grant maximum); 0 asks for the maximum; no maximum is +inf.""" |
| 472 | + """expires_at = min(requested, grant maximum); 0 asks for the maximum; no maximum is null.""" |
473 | 473 | before = int(time.time()) |
474 | 474 | ticket = _seal(_impl(ceiling), ttl_seconds=ttl) |
475 | 475 | claims = open_attach_ticket(_SIGNING_KEY, ticket.ticket, principal="alice") |
476 | 476 | if expected is None: |
477 | | - assert claims.expires_at == 0 and math.isinf(ticket.expires_at) |
| 477 | + assert claims.expires_at == 0 and ticket.expires_at is None |
478 | 478 | else: |
479 | 479 | assert before + expected <= claims.expires_at <= int(time.time()) + expected |
480 | | - assert ticket.expires_at == claims.expires_at |
| 480 | + assert ticket.expires_at == datetime.fromtimestamp(claims.expires_at, tz=UTC) |
481 | 481 |
|
482 | 482 | def test_max_ttl_from_grant_keys_and_env(self, monkeypatch: pytest.MonkeyPatch) -> None: |
483 | 483 | """Max ttl from grant keys and env.""" |
@@ -666,7 +666,8 @@ def test_grant_plus_ticket_reattaches_as_the_user(self, app: Any) -> None: |
666 | 666 | with http_connect(Identity, client=user) as identity: # type: ignore[type-abstract] |
667 | 667 | grant = identity.issue_grant(purpose="vgi.unattended", scopes=[], ttl_seconds=600) |
668 | 668 | assert sealed.ticket.startswith("vgia1.") and "sk-live-secret" not in sealed.ticket |
669 | | - assert sealed.expires_at <= time.time() + 7 * 24 * 3600 + 5 |
| 669 | + assert sealed.expires_at is not None |
| 670 | + assert sealed.expires_at <= datetime.now(UTC) + timedelta(days=7, seconds=5) |
670 | 671 |
|
671 | 672 | runner = _SyncTestClient(app, default_headers={"Authorization": f"Bearer {grant.token}"}) |
672 | 673 | reattached = _attach_over_http(runner, {"vgi_attach_ticket": sealed.ticket}) |
@@ -738,3 +739,28 @@ def test_fixture_http_server_hosts_attach_tickets_when_keyed(http_worker: Any) - |
738 | 739 | with http_connect(Reflection, base_url) as reflection: # type: ignore[type-abstract] |
739 | 740 | hosted = {p.protocol for p in reflection.list_protocols().protocols} |
740 | 741 | assert ATTACH_TICKETS_PROTOCOL_NAME in hosted |
| 742 | + |
| 743 | + |
| 744 | +class TestAttachTicketWireShape: |
| 745 | + """Protocol ``2.0.0``: ``expires_at`` is a nullable UTC microsecond timestamp.""" |
| 746 | + |
| 747 | + def test_protocol_version(self) -> None: |
| 748 | + """The exact major.minor check rejects a ``1.0.0`` (float64 ``expires_at``) peer.""" |
| 749 | + assert AttachTickets.protocol_version == "2.0.0" |
| 750 | + |
| 751 | + def test_expires_at_is_a_nullable_utc_timestamp(self) -> None: |
| 752 | + """``timestamp[us, tz="UTC"]``, nullable; null replaces ``+inf``.""" |
| 753 | + field = AttachTicket.ARROW_SCHEMA.field("expires_at") |
| 754 | + assert field.type == pa.timestamp("us", tz="UTC") |
| 755 | + assert field.nullable |
| 756 | + |
| 757 | + @pytest.mark.parametrize("when", [None, datetime(2026, 9, 21, 12, 0, 0, 123456, tzinfo=UTC)]) |
| 758 | + def test_round_trip(self, when: datetime | None) -> None: |
| 759 | + """Null and a microsecond-precision instant both survive the wire.""" |
| 760 | + got = AttachTicket.deserialize_from_bytes(AttachTicket(ticket="vgia1.x", expires_at=when).serialize_to_bytes()) |
| 761 | + assert got.expires_at == when |
| 762 | + |
| 763 | + def test_a_naive_datetime_is_refused(self) -> None: |
| 764 | + """Arrow would read a naive value as UTC, silently shifting local time.""" |
| 765 | + with pytest.raises(ValueError, match="timezone-aware"): |
| 766 | + AttachTicket(ticket="vgia1.x", expires_at=datetime(2026, 1, 1)) |
0 commit comments