"""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")