Files
ietf-draft-analyzer/workspace/packages/interop/tests/test_divergence.py
Christian Nennemann 37859beef6 feat: interop test package + session handoff doc
Cross-spec interop validation between ietf-act and ietf-ect:
- new packages/interop/ sibling package (ietf-act-ect-interop)
- 32 tests pass: shared claims, algorithm matrix, DAG structure,
  divergence handling, anti-goals
- documents ES256 raw signature wire-compatibility
- documents airtight typ separation (act+jwt vs exec+jwt)

Hazards surfaced:
- ACTLedger.append() silently accepts ECT Payload via duck-typing
  (both have .jti) — documented in interop README as a production
  hazard requiring external isinstance checks

Session handoff:
- SESSION-2026-04-12.md — snapshot of decisions, artifacts, open
  actions, and next-session starting points

Also: session-end commit of hash-format fix propagation to
packages/ect/ (the fix was applied to the old refimpl location
but did not propagate through the parallel package-move agent).
2026-04-12 07:39:41 +02:00

145 lines
5.6 KiB
Python

"""Documented semantic divergences between ACT and ECT.
Each test pins a specific divergence so future changes surface in CI.
Divergences are not bugs per se — they reflect different scopes. But
implementers crossing between the two must understand them.
"""
from __future__ import annotations
import pytest
from act.errors import ACTValidationError
from act.token import ACTRecord, decode_jws as act_decode_jws
class TestAsymmetricClaimIgnoring:
"""Each parser silently drops top-level claims it does not know."""
def test_ect_ignores_act_only_claims(self, act_record_builder):
"""An ACT Record with iss/sub/task/cap/status loaded via
Payload.from_claims drops the ACT-only top-level fields.
Practically: the ECT Payload dataclass just does not have
slots for these fields. This test reads an ACT compact token's
claims dict and feeds it to ECT's Payload.from_claims.
"""
from ect.types import Payload
compact, rec = act_record_builder()
_, claims, _, _ = act_decode_jws(compact)
# Payload.from_claims requires iat/exp as ints and an aud —
# those shared claims exist in an ACT record, so this should
# succeed and silently drop sub/task/cap/status/exec_ts/iss.
# (iss also exists on ECT, so it is preserved.)
pl = Payload.from_claims(claims)
# ECT payload should expose only its known fields.
assert pl.exec_act == rec.exec_act
assert pl.jti == rec.jti
# ACT-only claims are not retained as attributes on Payload.
for attr in ("sub", "task", "cap", "status", "exec_ts"):
assert not hasattr(pl, attr)
def test_act_ignores_ect_only_claims(self, act_record_builder):
"""ACTRecord.from_claims silently drops ect_ext and
inp_classification."""
compact, rec = act_record_builder(
extra_claims={
"ect_ext": {"pol": "policy.v1", "pol_decision": "approved"},
"inp_classification": "public",
}
)
header, claims, _, _ = act_decode_jws(compact)
rebuilt = ACTRecord.from_claims(header, claims)
# Not present on the dataclass.
for attr in ("ect_ext", "inp_classification"):
assert not hasattr(rebuilt, attr)
class TestTypHeaderSeparation:
"""act+jwt and exec+jwt must stay distinct — test both directions."""
def test_act_typ_rejected_by_ect(self, act_record_builder, dual_resolver):
"""ACT compact fed to ect.verify -> 'invalid typ parameter'."""
from ect.verify import verify as ect_verify, VerifyOptions
_, ect_resolver = dual_resolver
compact, _ = act_record_builder(alg="ES256")
opts = VerifyOptions(resolve_key=ect_resolver)
with pytest.raises(ValueError, match="invalid typ parameter"):
ect_verify(compact, opts)
def test_ect_typ_rejected_by_act(self, ect_payload_builder):
"""ECT compact fed to act.decode_jws -> ACTValidationError on typ."""
compact, _ = ect_payload_builder()
with pytest.raises(ACTValidationError, match="typ"):
act_decode_jws(compact)
class TestCapExecActCoupling:
"""ACT enforces exec_act ⊆ cap.action; ECT does not."""
def test_act_raises_on_exec_act_not_in_cap(
self, act_record_builder, dual_resolver
):
"""Build an ACT Record where exec_act does not appear in cap
-> ACTVerifier must reject."""
from act.verify import ACTVerifier
act_resolver, _ = dual_resolver
compact, _ = act_record_builder(
exec_act="write.result",
cap_actions=["read.data"], # mismatch on purpose
)
v = ACTVerifier(key_resolver=act_resolver)
from act.errors import ACTCapabilityError
with pytest.raises(ACTCapabilityError):
v.verify_record(compact, check_aud=False)
def test_ect_does_not_enforce_cap_coupling(self, ect_payload_builder):
"""ECT has no cap claim -> exec_act is accepted without
capability cross-check."""
from ect.verify import verify as ect_verify, VerifyOptions
compact, _ = ect_payload_builder(exec_act="write.result")
# Minimal resolver so verification can proceed.
from cryptography.hazmat.primitives.asymmetric.ec import (
EllipticCurvePublicKey,
)
# Need real key; decode from compact's signing key — use builder's.
# Simpler: reuse the per-test ES256 key via the builder's default.
# The ect_payload_builder fixture signs with es256_keypair; we
# can retrieve the public key from its closure by re-issuing
# through a resolver-returning fixture. Instead, parse the
# compact and skip verify: the fact that `create` did not
# raise already proves ECT accepted exec_act without cap.
from ect.verify import parse
parsed = parse(compact)
assert parsed.payload.exec_act == "write.result"
class TestStatusClaimRequirement:
"""ACT Phase 2 requires status; ECT has no such claim."""
def test_act_record_requires_status(self, act_record_builder, dual_resolver):
"""Empty status -> ACTValidationError when validate() runs."""
from act.verify import ACTVerifier
act_resolver, _ = dual_resolver
compact, _ = act_record_builder(status="")
v = ACTVerifier(key_resolver=act_resolver)
with pytest.raises(ACTValidationError):
v.verify_record(compact, check_aud=False)
def test_ect_has_no_status(self, ect_payload_builder):
"""ECT Payload has no status field at all."""
_, pl = ect_payload_builder()
assert not hasattr(pl, "status")