net-kingdom/sso-mfa/k8s/keycape/test_handover_adapter.py
tegwick cbadd359ba NK-WP-0048 r5 adapters: OW-1.3-C1 attended change lane, RAPPS-1.3-C1 human actor, RAPPS-1.3-C2 handover observations
Lane key-cape-oidc-login -> keycape-attended-case-change in the register-client,
ensure-groups and bind-env descriptors; deliver-consumer-secret actor agent ->
human; verify_consumer reports a value-free, exactly-once probe record list bound
into the observation digest. Composition artifacts, type.yaml and admit/compose
tests are left for the orchestrator to regenerate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 903097@bnt-lap001
Assistant-Session: aa320c8f-c3fa-46e7-8541-0b58e1e45d23
2026-10-17 11:59:14 +02:00

250 lines
13 KiB
Python

"""Synthetic positive and refusal fixtures for the T29 hand-over candidate."""
from dataclasses import asdict, replace
import importlib.util
import json
from pathlib import Path
import unittest
from warden.case_run import Descriptor, Invocation, OwnerResult, digest
from warden.case_receipt_state import Outcome, Pins, Refused
import yaml
ROOT = Path(__file__).parent
spec = importlib.util.spec_from_file_location('handover_adapter', ROOT / 'handover-adapter.py')
a = importlib.util.module_from_spec(spec)
spec.loader.exec_module(a)
PARAMS = {'client_id': 'forgejo', 'access_groups': 'forge-users,forge-admins'}
def case_bytes(type_=a.CANONICAL_TYPE, params=PARAMS):
return yaml.safe_dump({'spec': {'type': type_, 'parameters': params}}).encode()
class HandoverTests(unittest.TestCase):
def setUp(self):
self.case = case_bytes()
desc_bytes = json.dumps(asdict(a.descriptor('apps')), sort_keys=True, separators=(',', ':')).encode()
pins = Pins(digest(self.case), digest(b'type'), digest(desc_bytes), digest(b'catalog'),
digest(b'case-acceptance:test'), digest(b'run sheet'))
self.admitted = Invocation('hand-over', 'apps', 'forgejo', 'human', 'founder',
'apps-consumer-handover', pins)
self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run')
self.rb = replace(self.admitted, attempt_id='attempt-two', operation='rollback')
self.evidence, self.receipts, self.revoked = {}, set(), []
self.observed = {'client_id': 'forgejo', 'enforces_groups': True,
'groups': ['forge-admins', 'forge-users'], 'login_verified': True,
'observations': [{'probe': p, 'evidence': 'apps-probe:' + p}
for p in ('member-login', 'non-member-refused',
'membership-add-grants', 'membership-remove-revokes')]}
self.recorded = []
self.kw = dict(admitted=self.admitted, case_source=self.case, case_acceptance='case-acceptance:test',
verify_consumer=lambda c, g: dict(self.observed), record_handover=self.record,
remove_handover=self.remove, publish=self.publish,
authenticate=lambda ref, payload: self.evidence.get(ref) == payload,
revoke_session=self.revoke)
def revoke(self, inv):
self.revoked.append(inv)
return 'session-revocation:' + inv.attempt_id
def publish(self, payload):
ref = 'owner-evidence:' + str(len(self.evidence))
self.evidence[ref] = payload
return ref
def record(self, client_id, observation):
self.recorded.append(observation)
ref = 'handover:' + client_id
created = ref not in self.receipts
self.receipts.add(ref)
return ref, created
def remove(self, ref):
self.receipts.discard(ref)
return True
def test_positive_handover_verify_and_created_only_rollback(self):
adapter = a.build(**self.kw)
result = adapter.invoke(self.inv)
self.assertEqual((result.outcome, result.created), (Outcome.SUCCESS, (a.RECEIPT,)))
self.assertTrue(adapter.verify(self.inv, result))
self.assertNotIn(b'synthetic', self.evidence[result.evidence])
back = adapter.rollback(self.rb, (a.RECEIPT,))
self.assertEqual(back.outcome, Outcome.ROLLED_BACK)
self.assertEqual(self.receipts, set())
self.assertEqual(self.revoked, [self.inv, self.rb])
for res, inv in ((result, self.inv), (back, self.rb)):
self.assertIs(res.revoked, True)
self.assertEqual(res.session_revocation_evidence, 'session-revocation:' + inv.attempt_id)
self.assertIn(res.session_revocation_evidence.encode(), self.evidence[res.evidence])
def test_descriptor_decodes_in_warden_kernel_with_parameter_owner(self):
for owner in ('apps', 'railiance-apps'):
desc = a.descriptor(owner)
self.assertEqual((desc.owner, desc.revocation_scopes), (owner, ('parent-session',)))
raw = json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode()
self.assertEqual(Descriptor.decode(raw), desc)
for owner in (None, '', 'bad owner', 3):
with self.subTest(owner=owner), self.assertRaises(Refused):
a.descriptor(owner)
def test_parent_session_revoked_even_when_attempt_fails(self):
self.observed['login_verified'] = False
adapter = a.build(**self.kw)
with self.assertRaises(Refused):
adapter.invoke(self.inv)
self.assertEqual(self.revoked, [self.inv])
with self.assertRaises(Refused): # nothing created: rollback refused, session still revoked
adapter.rollback(self.rb, (a.RECEIPT,))
self.assertEqual(self.revoked, [self.inv, self.rb])
self.assertEqual(self.evidence, {})
def test_missing_revocation_evidence_refused_before_publication(self):
for bad in (None, '', 'bad id', 7):
with self.subTest(bad=bad), self.assertRaises(Refused):
a.build(**{**self.kw, 'revoke_session': lambda inv, b=bad: b}).invoke(self.inv)
self.assertEqual(self.evidence, {})
with self.assertRaises(Refused):
a.build(**{**self.kw, 'revoke_session': None})
def test_existing_receipt_is_no_change_and_not_rollbackable(self):
self.receipts.add('handover:forgejo')
adapter = a.build(**self.kw)
result = adapter.invoke(self.inv)
self.assertEqual((result.outcome, result.created), (Outcome.NO_CHANGE, ()))
with self.assertRaises(Refused):
adapter.rollback(self.rb, (a.RECEIPT,))
self.assertEqual(self.receipts, {'handover:forgejo'})
def test_rollback_refuses_unauthenticated_creation(self):
adapter = a.build(**{**self.kw, 'authenticate': lambda ref, payload: False})
adapter.invoke(self.inv)
with self.assertRaises(Refused):
adapter.rollback(self.rb, (a.RECEIPT,))
self.assertEqual(self.receipts, {'handover:forgejo'})
def test_consumer_verification_refusals(self):
for change in ({'enforces_groups': False}, {'login_verified': False}, {'groups': ['forge-users']},
{'client_id': 'other'}, {'extra': 1}):
self.observed = {**dict(self.observed), **change}
with self.subTest(change=change), self.assertRaises(Refused):
a.build(**self.kw).invoke(self.inv)
self.observed = {k: v for k, v in self.observed.items() if k != 'extra'}
self.setUp()
self.assertEqual(self.receipts, set())
def test_observation_record_refusals(self):
obs = self.observed['observations']
cases = {
'missing-field': None,
'not-a-list': {'member-login': 'apps-probe:x'},
'missing-probe': obs[:3],
'extra-probe': obs + [{'probe': 'admin-login', 'evidence': 'apps-probe:y'}],
'duplicate-probe': obs[:3] + [dict(obs[0])],
'unknown-probe': obs[:3] + [{'probe': 'admin-login', 'evidence': 'apps-probe:y'}],
'value-bearing-key': [{**obs[0], 'value': 'secret'}] + obs[1:],
'missing-evidence': [{'probe': 'member-login'}] + obs[1:],
'evidence-not-identifier': [{'probe': 'member-login', 'evidence': 'has space/x'}] + obs[1:],
'evidence-not-string': [{'probe': 'member-login', 'evidence': 7}] + obs[1:],
'record-not-dict': ['member-login'] + obs[1:],
}
for name, value in cases.items():
self.setUp()
if value is None:
del self.observed['observations']
else:
self.observed['observations'] = value
with self.subTest(name=name), self.assertRaises(Refused):
a.build(**self.kw).invoke(self.inv)
self.assertEqual(self.receipts, set())
def test_observations_bound_into_digest_order_independent(self):
def run(mutate):
self.setUp()
mutate(self.observed['observations'])
a.build(**self.kw).invoke(self.inv)
return self.recorded[-1]
base = run(lambda o: None)
self.assertEqual(base, run(lambda o: o.reverse()))
self.assertNotEqual(base, run(lambda o: o[0].update(evidence='apps-probe:changed')))
def test_canonical_default_and_explicit_selection_only(self):
with self.assertRaises(Refused):
a.build(**{**self.kw, 'version_root': '1.0'})
case12 = case_bytes(a.SELECTABLE_TYPES['1.2'])
pins = replace(self.admitted.pins, case=digest(case12))
admitted12 = replace(self.admitted, pins=pins)
with self.assertRaises(Refused): # 1.2 never reached by default
a.build(**{**self.kw, 'admitted': admitted12, 'case_source': case12})
with self.assertRaises(Refused): # 1.2 case must name consumer_owner_repo
a.build(**{**self.kw, 'admitted': admitted12, 'case_source': case12, 'version_root': '1.2'})
case12 = case_bytes(a.SELECTABLE_TYPES['1.2'], {**PARAMS, 'consumer_owner_repo': 'railiance-apps'})
desc = json.dumps(asdict(a.descriptor('railiance-apps')), sort_keys=True, separators=(',', ':')).encode()
admitted12 = replace(self.admitted, owner='railiance-apps',
pins=replace(self.admitted.pins, case=digest(case12), adapter=digest(desc)))
kw12 = {**self.kw, 'admitted': admitted12, 'case_source': case12, 'version_root': '1.2'}
inv = replace(admitted12, attempt_id='attempt-one', operation='run')
self.assertEqual(a.build(**kw12).invoke(inv).outcome, Outcome.SUCCESS)
with self.assertRaises(Refused): # admitted owner differs from the case's consumer_owner_repo
a.build(**{**kw12, 'admitted': replace(admitted12, owner='apps')})
def test_1_3_explicit_selection_with_1_2_owner_handling(self):
case13 = case_bytes(a.SELECTABLE_TYPES['1.3'])
admitted13 = replace(self.admitted, pins=replace(self.admitted.pins, case=digest(case13)))
with self.assertRaises(Refused): # 1.3 never reached by default
a.build(**{**self.kw, 'admitted': admitted13, 'case_source': case13})
with self.assertRaises(Refused): # 1.3 case must name consumer_owner_repo
a.build(**{**self.kw, 'admitted': admitted13, 'case_source': case13, 'version_root': '1.3'})
case13 = case_bytes(a.SELECTABLE_TYPES['1.3'], {**PARAMS, 'consumer_owner_repo': 'railiance-apps'})
desc = json.dumps(asdict(a.descriptor('railiance-apps')), sort_keys=True, separators=(',', ':')).encode()
admitted13 = replace(self.admitted, owner='railiance-apps',
pins=replace(self.admitted.pins, case=digest(case13), adapter=digest(desc)))
kw13 = {**self.kw, 'admitted': admitted13, 'case_source': case13, 'version_root': '1.3'}
with self.assertRaises(Refused): # a 1.3 case under the 1.2 root
a.build(**{**kw13, 'version_root': '1.2'})
inv = replace(admitted13, attempt_id='attempt-one', operation='run')
self.assertEqual(a.build(**kw13).invoke(inv).outcome, Outcome.SUCCESS)
with self.assertRaises(Refused): # admitted owner differs from the case's consumer_owner_repo
a.build(**{**kw13, 'admitted': replace(admitted13, owner='apps')})
def test_binding_refusals(self):
bad = [{'case_acceptance': 'other'}, {'case_source': self.case + b'\n#'},
{'admitted': replace(self.admitted, owner='net-kingdom')},
{'admitted': replace(self.admitted, target='other')},
{'admitted': replace(self.admitted, pins=replace(self.admitted.pins, adapter=digest(b'x')))},
{'publish': None}]
for change in bad:
with self.subTest(change=list(change)), self.assertRaises(Refused):
a.build(**{**self.kw, **change})
no_groups = case_bytes(params={'client_id': 'forgejo'})
with self.assertRaises(Refused):
a.build(**{**self.kw, 'case_source': no_groups,
'admitted': replace(self.admitted, pins=replace(self.admitted.pins, case=digest(no_groups)))})
def test_attempt_and_contract_refusals(self):
adapter = a.build(**self.kw)
for inv in (replace(self.inv, principal='other'), replace(self.inv, operation='rollback'),
replace(self.inv, attempt_id='')):
with self.subTest(inv=inv), self.assertRaises(Refused):
adapter.invoke(inv)
for receipt in (('handover:forgejo', 'yes'), 'handover:forgejo', ('', True)):
with self.subTest(receipt=receipt), self.assertRaises(Refused):
a.build(**{**self.kw, 'record_handover': lambda c, o, r=receipt: r}).invoke(self.inv)
reuse = a.build(**{**self.kw, 'publish': lambda p: 'owner-evidence:0'})
reuse.invoke(self.inv)
with self.assertRaises(Refused): # evidence identifier reuse
reuse.invoke(replace(self.inv, attempt_id='attempt-three'))
self.assertFalse(adapter.verify(self.inv, OwnerResult(Outcome.SUCCESS, 'forged')))
def test_removal_unconfirmed_keeps_creation(self):
adapter = a.build(**{**self.kw, 'remove_handover': lambda ref: None})
adapter.invoke(self.inv)
with self.assertRaises(Refused):
adapter.rollback(self.rb, (a.RECEIPT,))
with self.assertRaises(Refused):
adapter.rollback(self.rb, ())
if __name__ == '__main__':
unittest.main()