Per coordinator r6. New register-client descriptor sha256:434c53e8...43cb; 1.2/1.3 compositions regenerated; 1.3 map and status-only ready_check updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 696317@bnt-lap001 Assistant-Session: bab9e511-0915-45b3-926a-2fab552edff6
419 lines
22 KiB
Python
419 lines
22 KiB
Python
"""Actual helper/CAS execution against synthetic Kubernetes objects only."""
|
|
from dataclasses import asdict, replace
|
|
import copy
|
|
import importlib.util
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from warden.case_run import Invocation, digest
|
|
from warden.case_receipt_state import Outcome, Pins, Refused
|
|
import yaml
|
|
|
|
ROOT = Path(__file__).parent
|
|
spec = importlib.util.spec_from_file_location('registration_adapter', ROOT / 'keycape-registration-adapter.py')
|
|
a = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(a)
|
|
spec = importlib.util.spec_from_file_location('registration_fixture', ROOT / 'test_keycape_client.py')
|
|
f = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(f)
|
|
|
|
|
|
ISSUER = 'https://kc.coulomb.social'
|
|
|
|
|
|
class FakeIssuer:
|
|
"""Single-replica Recreate issuer; becomes Ready once a restart stamp is written."""
|
|
|
|
def __init__(self, outcome='ready'):
|
|
self.outcome, self.writes, self.sleeps = outcome, [], []
|
|
self.deployment = {'kind': 'Deployment',
|
|
'metadata': {'name': 'keycape', 'namespace': 'sso', 'resourceVersion': '40', 'generation': 3},
|
|
'spec': {'replicas': 1, 'strategy': {'type': 'Recreate'},
|
|
'template': {'spec': {'containers': [{'name': 'keycape'}]}}},
|
|
'status': {'observedGeneration': 3, 'updatedReplicas': 1, 'readyReplicas': 1, 'availableReplicas': 1}}
|
|
|
|
def read(self):
|
|
return copy.deepcopy(self.deployment)
|
|
|
|
def write(self, deployment, expected):
|
|
assert expected == self.deployment['metadata']['resourceVersion']
|
|
self.writes.append(copy.deepcopy(deployment))
|
|
self.deployment = copy.deepcopy(deployment)
|
|
self.deployment['metadata']['resourceVersion'] = str(int(expected) + 1)
|
|
self.deployment['metadata']['generation'] += 1
|
|
self.deployment['status']['observedGeneration'] += 1
|
|
return self.deployment['metadata']['resourceVersion']
|
|
|
|
def observe(self):
|
|
stamp = self.deployment['spec']['template'].get('metadata', {}).get('annotations', {}).get(
|
|
a.restart_readiness.ANNOTATION)
|
|
state = {'waiting': {'reason': 'CrashLoopBackOff'}} if self.outcome == 'crash' else {'running': {}}
|
|
ready = 'False' if self.outcome in ('crash', 'stuck') else 'True'
|
|
pod = {'metadata': {'annotations': {a.restart_readiness.ANNOTATION: stamp}},
|
|
'status': {'phase': 'Running', 'conditions': [{'type': 'Ready', 'status': ready}],
|
|
'containerStatuses': [{'name': 'keycape', 'state': state}]}}
|
|
discovery = {'issuer': ISSUER, 'authorization_endpoint': ISSUER + '/authorize',
|
|
'token_endpoint': ISSUER + '/token', 'jwks_uri': ISSUER + '/jwks'}
|
|
return copy.deepcopy(self.deployment), [pod], discovery if ready == 'True' else None
|
|
|
|
def capabilities(self):
|
|
return {'read': self.read, 'write': self.write, 'observe': self.observe,
|
|
'sleep': self.sleeps.append, 'issuer': ISSUER}
|
|
|
|
|
|
class RegistrationTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp = tempfile.TemporaryDirectory()
|
|
self.binary = Path(self.temp.name) / 'keycape'
|
|
self.binary.write_bytes(b'synthetic checker binary')
|
|
self.case = yaml.safe_dump({'spec': {'type': 'oidc-browser-relying-party@1.1',
|
|
'parameters': f.FORGEJO_PARAMS}}).encode()
|
|
desc = a.descriptor(digest(self.binary.read_bytes()))
|
|
self.desc_bytes = json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode()
|
|
pins = Pins(digest(self.case), digest(b'type'), digest(self.desc_bytes),
|
|
digest(b'catalog'), digest(b'case-acceptance:test'), digest(b'run sheet'))
|
|
self.admitted = Invocation('register-client', 'net-kingdom', 'forgejo', 'human',
|
|
'founder', 'keycape-attended-case-change', pins)
|
|
self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run')
|
|
self.secret, self.raw = f.fixture()
|
|
self.effects = []
|
|
self.evidence = {}
|
|
self.revocations = []
|
|
self.kw = dict(admitted=self.admitted, case_source=self.case,
|
|
expected_uid=f.UID, expected_resource_version='7', case_acceptance='case-acceptance:test',
|
|
check_config=self.binary, check_config_digest=digest(self.binary.read_bytes()),
|
|
publish=self.publish, authenticate=lambda ref, payload: self.evidence.get(ref) == payload,
|
|
revoke_session=self.revoke, restart=None)
|
|
self.issuer = FakeIssuer()
|
|
self.kw['restart'] = self.issuer.capabilities()
|
|
self.check_patch = patch.object(a.helper, 'check_config', return_value={'valid': True})
|
|
self.kube_patch = patch.object(a.helper.lane, 'kubectl', side_effect=self.kube)
|
|
self.check_patch.start()
|
|
self.kube_patch.start()
|
|
self.addCleanup(self.check_patch.stop)
|
|
self.addCleanup(self.kube_patch.stop)
|
|
self.addCleanup(self.temp.cleanup)
|
|
|
|
def publish(self, payload):
|
|
ref = 'owner-evidence:attempt-' + str(len(self.evidence))
|
|
self.evidence[ref] = payload
|
|
return ref
|
|
|
|
def revoke(self, inv):
|
|
self.revocations.append(inv)
|
|
return 'session-revoked:' + inv.operation + ':' + inv.attempt_id
|
|
|
|
def kube(self, args, payload=None):
|
|
if args[:3] == ['get', 'namespace', 'kube-system']:
|
|
return {'metadata': {'uid': a.helper.lane.CLUSTER_UID}}
|
|
if args[:5] == ['-n', 'sso', 'get', 'secret', 'keycape-config']:
|
|
return copy.deepcopy(self.secret)
|
|
if args[:5] == ['-n', 'sso', 'get', 'deployment', 'keycape']:
|
|
return {'spec': {'template': {'spec': {'containers': [{'name': 'keycape',
|
|
'env': [a.helper.env_binding(f.FORGEJO)]}]}}}}
|
|
if args[:5] == ['-n', 'sso', 'patch', 'secret', 'keycape-config']:
|
|
self.assertEqual(payload[:2], [
|
|
{'op': 'test', 'path': '/metadata/uid', 'value': self.secret['metadata']['uid']},
|
|
{'op': 'test', 'path': '/metadata/resourceVersion', 'value': self.secret['metadata']['resourceVersion']}])
|
|
self.effects.append(copy.deepcopy(payload))
|
|
self.secret['data']['config.yaml'] = payload[2]['value']
|
|
self.secret['metadata']['resourceVersion'] = str(int(self.secret['metadata']['resourceVersion']) + 1)
|
|
return copy.deepcopy(self.secret)
|
|
raise AssertionError('unexpected tooling operation')
|
|
|
|
def test_original_reconcile_capabilities_are_optional_but_paired(self):
|
|
self.assertIsNone(a.build(**self.kw).reconcile_original)
|
|
for extra in ({'holder': lambda attempt: []}, {'observe': lambda: {}},
|
|
{'holder': 'cmd', 'observe': lambda: {}}):
|
|
with self.subTest(extra=sorted(extra)), self.assertRaisesRegex(Refused, 'together'):
|
|
a.build(**dict(self.kw, **extra))
|
|
adapter = a.build(**dict(self.kw, holder=lambda attempt: [], observe=lambda: {}))
|
|
self.assertTrue(callable(adapter.reconcile_original))
|
|
|
|
def test_actual_add_and_created_only_rollback_preserve_original_bytes(self):
|
|
adapter = a.build(**self.kw)
|
|
original = copy.deepcopy(self.secret['data'])
|
|
result = adapter.invoke(self.inv)
|
|
self.assertEqual(result.outcome, Outcome.SUCCESS)
|
|
self.assertEqual(result.created, ('keycape-client',))
|
|
self.assertTrue(adapter.verify(self.inv, result))
|
|
undone = adapter.rollback(replace(self.inv, attempt_id='attempt-two', operation='rollback'), result.created)
|
|
self.assertEqual(undone.outcome, Outcome.ROLLED_BACK)
|
|
self.assertEqual(self.secret['data'], original)
|
|
self.assertEqual(len(self.effects), 2)
|
|
self.assertEqual(len(self.revocations), 2)
|
|
for payload in self.evidence.values():
|
|
self.assertNotIn(b'synthetic-marker', payload)
|
|
self.assertNotIn(b'config.yaml', payload)
|
|
self.assertNotIn(b'signing-key', payload)
|
|
|
|
def test_changed_registry_restarts_issuer_and_records_ready_verdict(self):
|
|
adapter = a.build(**self.kw)
|
|
result = adapter.invoke(self.inv)
|
|
self.assertEqual(result.outcome, Outcome.SUCCESS)
|
|
self.assertEqual(len(self.issuer.writes), 1)
|
|
payload = json.loads(self.evidence[result.evidence])
|
|
self.assertEqual(payload['issuer_restart']['verdict'], 'ready')
|
|
self.assertEqual(payload['issuer_restart']['before'], '40')
|
|
undone = adapter.rollback(replace(self.inv, attempt_id='attempt-two', operation='rollback'), result.created)
|
|
self.assertEqual(undone.outcome, Outcome.ROLLED_BACK)
|
|
self.assertEqual(len(self.issuer.writes), 2)
|
|
|
|
def test_no_change_does_not_restart_issuer(self):
|
|
self.secret, _ = f.fixture(f.EXISTING + [f.FORGEJO])
|
|
result = a.build(**self.kw).invoke(self.inv)
|
|
self.assertEqual(result.outcome, Outcome.NO_CHANGE)
|
|
self.assertFalse(self.issuer.writes)
|
|
self.assertIsNone(json.loads(self.evidence[result.evidence])['issuer_restart'])
|
|
|
|
def test_failed_or_stuck_restart_is_uncertain_and_revokes_session(self):
|
|
for outcome in ('crash', 'stuck'):
|
|
with self.subTest(outcome=outcome):
|
|
self.setUp()
|
|
self.issuer = FakeIssuer(outcome)
|
|
adapter = a.build(**dict(self.kw, restart=self.issuer.capabilities()))
|
|
with self.assertRaises(Refused):
|
|
adapter.invoke(self.inv)
|
|
self.assertEqual(len(self.issuer.writes), 1)
|
|
self.assertFalse(self.evidence)
|
|
self.assertEqual(self.revocations, [self.inv])
|
|
if outcome == 'stuck':
|
|
self.assertEqual(len(self.issuer.sleeps), a.RESTART_ATTEMPTS - 1)
|
|
|
|
def test_restart_capabilities_are_required_and_bounded(self):
|
|
caps = self.issuer.capabilities()
|
|
for bad in (None, {}, dict(caps, issuer='http://kc.coulomb.social'),
|
|
dict(caps, write='not-callable'), dict(caps, extra=print)):
|
|
with self.subTest(bad=bad if not isinstance(bad, dict) else sorted(bad)):
|
|
with self.assertRaisesRegex(Refused, 'restart'):
|
|
a.build(**dict(self.kw, restart=bad))
|
|
|
|
def test_adapter_refuses_symlink_fifo_and_oversized_source_before_helper(self):
|
|
symlink = self.binary.with_name('symlink')
|
|
symlink.symlink_to(self.binary)
|
|
fifo = self.binary.with_name('fifo')
|
|
os.mkfifo(fifo)
|
|
oversized = self.binary.with_name('oversized')
|
|
with oversized.open('wb') as output:
|
|
output.truncate(64 * 1024 * 1024 + 1)
|
|
for source in (symlink, fifo, oversized):
|
|
with self.subTest(source=source.name), patch.object(a.helper, 'run') as run:
|
|
adapter = a.build(**dict(self.kw, check_config=source))
|
|
with self.assertRaises(Refused):
|
|
adapter.invoke(self.inv)
|
|
run.assert_not_called()
|
|
self.assertEqual(self.revocations, [self.inv] * 3)
|
|
self.assertFalse(self.effects)
|
|
self.assertFalse(self.evidence)
|
|
|
|
def test_snapshot_unavailable_refuses_before_tooling_and_revokes_session(self):
|
|
adapter = a.build(**self.kw)
|
|
with patch.object(a.os, 'memfd_create', side_effect=OSError('synthetic policy denial')):
|
|
with self.assertRaises(Refused):
|
|
adapter.invoke(self.inv)
|
|
self.assertFalse(self.effects)
|
|
self.assertFalse(self.evidence)
|
|
self.assertEqual(self.revocations, [self.inv])
|
|
|
|
def test_identical_client_is_no_change_and_cannot_be_removed(self):
|
|
self.secret, _ = f.fixture(f.EXISTING + [f.FORGEJO])
|
|
adapter = a.build(**self.kw)
|
|
result = adapter.invoke(self.inv)
|
|
self.assertEqual(result.outcome, Outcome.NO_CHANGE)
|
|
self.assertEqual(result.created, ())
|
|
with self.assertRaises(Refused):
|
|
adapter.rollback(replace(self.inv, operation='rollback'), ('keycape-client',))
|
|
self.assertFalse(self.effects)
|
|
|
|
def test_existing_drift_refused_and_session_revoked(self):
|
|
self.secret, _ = f.fixture(f.EXISTING + [dict(f.FORGEJO, displayName='Drift')])
|
|
with self.assertRaises(Refused):
|
|
a.build(**self.kw).invoke(self.inv)
|
|
self.assertFalse(self.effects)
|
|
self.assertEqual(self.revocations, [self.inv])
|
|
|
|
def test_invocation_rebinding_refused_before_tooling(self):
|
|
for changes in ({'target': 'other'}, {'principal': 'other'}, {'actor': 'agent'},
|
|
{'lane': 'other'}, {'operation': 'rollback'}, {'attempt_id': ''},
|
|
{'pins': replace(self.inv.pins, case=digest(b'other'))}):
|
|
with self.subTest(changes=changes), self.assertRaises(Refused):
|
|
a.build(**self.kw).invoke(replace(self.inv, **changes))
|
|
self.assertFalse(self.effects)
|
|
|
|
def test_initial_case_descriptor_and_target_pins_are_required(self):
|
|
for changes in ({'case_source': b'{}'}, {'case_acceptance': 'other'}, {'admitted': replace(self.admitted, target='other')},
|
|
{'admitted': replace(self.admitted, actor='agent')},
|
|
{'admitted': replace(self.admitted, pins=replace(self.admitted.pins, adapter=digest(b'wrong')))}):
|
|
with self.subTest(changes=changes), self.assertRaises(Refused):
|
|
a.build(**dict(self.kw, **changes))
|
|
self.assertFalse(self.effects)
|
|
|
|
def test_binary_or_source_drift_refused_before_tooling(self):
|
|
adapter = a.build(**self.kw)
|
|
self.binary.write_bytes(b'changed')
|
|
with self.assertRaises(Refused):
|
|
adapter.invoke(self.inv)
|
|
self.binary.write_bytes(b'synthetic checker binary')
|
|
with patch.object(a, 'source_digest', return_value=digest(b'changed')):
|
|
with self.assertRaises(Refused):
|
|
adapter.invoke(self.inv)
|
|
self.assertFalse(self.effects)
|
|
|
|
def test_substituting_caller_selected_checker_under_same_descriptor_is_refused(self):
|
|
substitute = Path(self.temp.name) / 'other-checker'
|
|
substitute.write_bytes(b'caller-selected executable')
|
|
with self.assertRaisesRegex(Refused, 'descriptor pin differs'):
|
|
a.build(**dict(self.kw, check_config=substitute,
|
|
check_config_digest=digest(substitute.read_bytes())))
|
|
self.assertFalse(self.effects)
|
|
|
|
def test_stale_revision_refuses_mutation(self):
|
|
adapter = a.build(**self.kw)
|
|
self.secret['metadata']['resourceVersion'] = '8'
|
|
with self.assertRaises(Refused):
|
|
adapter.invoke(self.inv)
|
|
self.assertFalse(self.effects)
|
|
|
|
def test_rollback_requires_exact_provenance_and_postcreation_revision(self):
|
|
adapter = a.build(**self.kw)
|
|
result = adapter.invoke(self.inv)
|
|
rollback = replace(self.inv, attempt_id='attempt-two', operation='rollback')
|
|
for created in ((), ('other',), ('keycape-client', 'other')):
|
|
with self.subTest(created=created), self.assertRaises(Refused):
|
|
adapter.rollback(rollback, created)
|
|
self.secret['metadata']['resourceVersion'] = '9'
|
|
with self.assertRaises(Refused):
|
|
adapter.rollback(rollback, result.created)
|
|
self.assertEqual(len(self.effects), 1)
|
|
|
|
def test_missing_or_tampered_evidence_never_verifies_or_authorizes_undo(self):
|
|
adapter = a.build(**self.kw)
|
|
result = adapter.invoke(self.inv)
|
|
self.assertFalse(adapter.verify(replace(self.inv, attempt_id='other'), result))
|
|
self.assertFalse(adapter.verify(self.inv, replace(result, revoked=False)))
|
|
self.evidence[result.evidence] = b'forged'
|
|
self.assertFalse(adapter.verify(self.inv, result))
|
|
with self.assertRaises(Refused):
|
|
adapter.rollback(replace(self.inv, operation='rollback'), result.created)
|
|
self.assertEqual(len(self.effects), 1)
|
|
|
|
def test_process_loss_does_not_reconstruct_created_authority(self):
|
|
result = a.build(**self.kw).invoke(self.inv)
|
|
fresh = a.build(**self.kw)
|
|
with self.assertRaises(Refused):
|
|
fresh.rollback(replace(self.inv, operation='rollback'), result.created)
|
|
self.assertIsNone(fresh.reconcile)
|
|
self.assertEqual(len(self.effects), 1)
|
|
|
|
def test_failed_readback_is_uncertain_and_never_provides_creation(self):
|
|
original_kube = self.kube
|
|
def lost(args, payload=None):
|
|
if self.effects and args[:5] == ['-n', 'sso', 'get', 'secret', 'keycape-config']:
|
|
raise RuntimeError('synthetic sensitive detail')
|
|
return original_kube(args, payload)
|
|
with patch.object(a.helper.lane, 'kubectl', side_effect=lost):
|
|
adapter = a.build(**self.kw)
|
|
with self.assertRaisesRegex(Refused, '^owner callback failed$'):
|
|
adapter.invoke(self.inv)
|
|
self.assertEqual(len(self.effects), 1)
|
|
self.assertFalse(self.evidence)
|
|
self.assertEqual(self.revocations, [self.inv])
|
|
|
|
def test_revocation_or_publication_failure_cannot_produce_verified_success(self):
|
|
for capability in ('publish', 'revoke_session'):
|
|
self.secret, _ = f.fixture()
|
|
with self.subTest(capability=capability):
|
|
kw = dict(self.kw, **{capability: lambda *args: (_ for _ in ()).throw(RuntimeError('private'))})
|
|
adapter = a.build(**kw)
|
|
with self.assertRaisesRegex(Refused, '^owner callback failed$'):
|
|
adapter.invoke(self.inv)
|
|
self.assertFalse(self.evidence)
|
|
|
|
def kernel_fixture(self):
|
|
# Fixture-only proposed type/case; canonical files are never edited.
|
|
# Warden's published producer pins apply unchanged. Owner trust and
|
|
# proposed descriptor/type/case are fixture-only, never native admission.
|
|
import warden.case_inputs as inputs
|
|
from warden.case_run import Executor, Journal
|
|
root = ROOT.parents[2]
|
|
case = yaml.safe_load((root / 'security-cases/forgejo-netkingdom-oidc.yaml').read_bytes())
|
|
case['spec']['status'] = 'materialized'
|
|
case_source = yaml.safe_dump(case).encode()
|
|
typ = yaml.safe_load((root / 'security-case-types/oidc-browser-relying-party/type.yaml').read_bytes())
|
|
typ['spec']['execution_descriptors'] = {'register-client': digest(self.desc_bytes)}
|
|
# Fixture-only successor lane for the adapter's current descriptor.
|
|
# Canonical 1.1 still names the earlier login lane and is not edited.
|
|
typ['spec']['follow_through'] = [
|
|
dict(step, lane=self.admitted.lane) if step['id'] == 'register-client' else step
|
|
for step in typ['spec']['follow_through']]
|
|
type_source = yaml.safe_dump(typ).encode()
|
|
catalog = (ROOT / 'kernel-catalog.fixture.yaml').read_bytes()
|
|
sheet = json.dumps(['register-client', 'net-kingdom', 'forgejo', 'human',
|
|
'founder', 'keycape-attended-case-change', 'fixture-acceptance', 'fixture-materialization',
|
|
'fixture-descriptor', 'fixture-session'], separators=(',', ':')).encode()
|
|
pins = Pins(digest(case_source), digest(type_source), digest(self.desc_bytes),
|
|
digest(catalog), digest(b'fixture-acceptance'), digest(sheet))
|
|
invocation = replace(self.admitted, pins=pins)
|
|
adapter = a.build(**dict(self.kw, admitted=invocation, case_source=case_source,
|
|
case_acceptance='fixture-acceptance'))
|
|
def fixture_verifier(purpose, source, evidence):
|
|
if purpose == 'catalog-routing-acceptance':
|
|
return source == digest(catalog) and evidence == 'fixture-descriptor'
|
|
if purpose.startswith('session-revocation:'):
|
|
_, lane, operation, attempt = purpose.split(':')
|
|
return source == digest(case_source) and lane == invocation.lane and evidence == (
|
|
'session-revoked:' + operation + ':' + attempt)
|
|
return evidence in {'fixture-acceptance', 'fixture-materialization',
|
|
'fixture-descriptor', 'fixture-session'}
|
|
executor = Executor(Journal(Path(self.temp.name) / 'private'),
|
|
{adapter.reference: adapter}, fixture_verifier, browser_inputs=inputs.BrowserInputs(root))
|
|
admitted = executor.admit(case_source, type_source, self.desc_bytes, catalog,
|
|
principal='founder', actor='human', lane=invocation.lane,
|
|
acceptance_evidence='fixture-acceptance', materialization_evidence='fixture-materialization',
|
|
descriptor_evidence='fixture-descriptor', session_evidence='fixture-session')
|
|
self.assertEqual(admitted[1], invocation)
|
|
return executor, admitted
|
|
|
|
def test_actual_kernel_admission_dispatch_and_rollback_with_fixture_only_trust(self):
|
|
executor, admitted = self.kernel_fixture()
|
|
result = executor.run(*admitted)
|
|
self.assertEqual(result.last.outcome, Outcome.SUCCESS)
|
|
self.assertEqual(result.last.created, ('keycape-client',))
|
|
undone = executor.rollback(*admitted)
|
|
self.assertEqual(undone.last.outcome, Outcome.ROLLED_BACK)
|
|
self.assertEqual(len(self.effects), 2)
|
|
self.assertEqual(len(self.revocations), 2)
|
|
|
|
def test_kernel_lost_readback_retains_uncertain_and_refuses_replay_and_undo(self):
|
|
executor, admitted = self.kernel_fixture()
|
|
original_kube = self.kube
|
|
def lost(args, payload=None):
|
|
if self.effects and args[:5] == ['-n', 'sso', 'get', 'secret', 'keycape-config']:
|
|
raise RuntimeError('synthetic sensitive detail')
|
|
return original_kube(args, payload)
|
|
with patch.object(a.helper.lane, 'kubectl', side_effect=lost):
|
|
result = executor.run(*admitted)
|
|
self.assertEqual(result.last.outcome, Outcome.UNCERTAIN)
|
|
self.assertFalse(result.last.created)
|
|
with self.assertRaises(Refused):
|
|
executor.run(*admitted, retry=True)
|
|
with self.assertRaises(Refused):
|
|
executor.rollback(*admitted)
|
|
self.assertEqual(len(self.effects), 1)
|
|
self.assertEqual(len(self.revocations), 1)
|
|
|
|
def test_kernel_external_revision_change_retains_uncertain_rollback(self):
|
|
executor, admitted = self.kernel_fixture()
|
|
self.assertEqual(executor.run(*admitted).last.outcome, Outcome.SUCCESS)
|
|
self.secret['metadata']['resourceVersion'] = '9'
|
|
self.assertEqual(executor.rollback(*admitted).last.outcome, Outcome.ROLLBACK_UNCERTAIN)
|
|
self.assertEqual(len(self.effects), 1)
|
|
self.assertEqual(len(self.revocations), 2)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|