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).
This commit is contained in:
144
workspace/packages/interop/tests/test_divergence.py
Normal file
144
workspace/packages/interop/tests/test_divergence.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""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")
|
||||
Reference in New Issue
Block a user