net-kingdom/tests/test_reset_role_transition.py

204 lines
8.9 KiB
Python
Raw Normal View History

"""Exact membership effects, durable interruption recovery and attended CLI boundaries."""
import copy
import importlib.util
import json
import os
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location(
'reset_transition', ROOT / 'sso-mfa/k8s/lldap/reset-role-transition.py')
change = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(change)
DIGEST = 'sha256:' + 'a' * 64
def baseline():
return {'users': ['admin', 'other', change.TARGET, change.SUBJECT], 'groups': {
change.OLD: {'id': 1, 'members': [change.TARGET]},
change.NEW: {'id': 2, 'members': []},
'lldap_admin': {'id': 3, 'members': ['admin']},
'tenant:one:users': {'id': 4, 'members': [change.SUBJECT]}}}
class Directory:
def __init__(self, state=None, failure=None, when=1):
self.state = state or baseline()
self.calls = []
self.failure, self.when = failure, when
def snapshot(self):
return copy.deepcopy(self.state)
def membership(self, action, group_id):
self.calls.append((action, group_id))
failing = len(self.calls) == self.when
if failing and self.failure == 'before':
raise RuntimeError('private-provider-error')
group = next(g for g in self.state['groups'].values() if g['id'] == group_id)
if action == 'add':
group['members'].append(change.TARGET)
group['members'].sort()
else:
group['members'].remove(change.TARGET)
if failing and self.failure == 'drift':
self.state['groups']['tenant:one:users']['members'].insert(0, 'other')
if failing and self.failure == 'after':
raise RuntimeError('private-provider-error')
@pytest.fixture
def journal(tmp_path):
tmp_path.chmod(0o700)
value = change.Journal(tmp_path / 'attempt')
try:
yield value
finally:
value.close()
def test_grant_and_rollback_preserve_all_other_memberships_and_receipt_history(journal):
directory = Directory()
result = change.transition(directory, journal, DIGEST, 'grant')
assert result['status'] == 'password_manager_role_verified'
assert result['other_directory_memberships_unchanged']
assert directory.state == change.expected_state(baseline(), change.NEW)
assert directory.calls == [('remove', 1), ('add', 2)]
original_receipt = journal.read()
# Rollback preserves productive changes made after the grant, including subject changes.
directory.state['groups']['tenant:one:users']['members'].insert(0, 'other')
directory.state['groups']['lldap_admin']['members'].append(change.SUBJECT)
before_rollback = directory.snapshot()
result = change.transition(directory, journal, DIGEST, 'rollback')
assert result['status'] == 'read_only_role_restored_verified'
assert directory.state == change.expected_state(before_rollback, change.OLD)
history = [json.loads(p.read_text()) for p in journal.path.glob('history-*.json')]
assert original_receipt in history
with pytest.raises(change.Refused, match='cannot_replay'):
change.transition(directory, journal, DIGEST, 'rollback')
@pytest.mark.parametrize('failure,when', [('before', 1), ('after', 1), ('after', 2),
('drift', 1), ('drift', 2)])
def test_ambiguous_effects_recover_only_readonly_and_preserve_foreign_changes(journal, failure, when):
directory = Directory(failure=failure, when=when)
with pytest.raises(change.Refused, match='uncertain'):
change.transition(directory, journal, DIGEST, 'grant')
assert journal.read()['status'] == 'uncertain_requires_attended_recovery'
with pytest.raises(change.Refused, match='not_replay'):
change.transition(directory, journal, DIGEST, 'grant')
directory.failure = None
before = directory.snapshot()
result = change.transition(directory, journal, DIGEST, 'recover')
assert result['status'] == 'read_only_role_restored_verified'
assert directory.state == change.expected_state(before, change.OLD)
assert 'private-provider-error' not in json.dumps(journal.read())
@pytest.mark.parametrize('defect', ['foreign-role', 'already-granted', 'no-admin',
'accepted-admin', 'missing-subject', 'missing-group'])
def test_unreviewed_baseline_refuses_before_any_journal_or_write(journal, defect):
state = baseline()
if defect == 'foreign-role':
state['groups']['tenant:one:users']['members'].insert(0, change.TARGET)
elif defect == 'already-granted':
state = change.expected_state(state, change.NEW)
elif defect == 'no-admin':
state['groups']['lldap_admin']['members'] = []
elif defect == 'accepted-admin':
state['groups']['lldap_admin']['members'].append(change.SUBJECT)
elif defect == 'missing-subject':
state['users'].remove(change.SUBJECT)
else:
del state['groups'][change.NEW]
directory = Directory(state)
with pytest.raises(change.Refused):
change.transition(directory, journal, DIGEST, 'grant')
assert not directory.calls and journal.read() is None
@pytest.mark.parametrize('defect', ['digest', 'group-id', 'foreign-role'])
def test_recovery_refuses_changed_binding(journal, defect):
directory = Directory(failure='after')
with pytest.raises(change.Refused):
change.transition(directory, journal, DIGEST, 'grant')
if defect == 'group-id':
directory.state['groups'][change.NEW]['id'] = 99
if defect == 'foreign-role':
directory.state['groups']['lldap_admin']['members'].append(change.TARGET)
directory.calls.clear()
with pytest.raises(change.Refused):
change.transition(directory, journal, DIGEST + ('x' if defect == 'digest' else ''), 'recover')
assert not directory.calls
@pytest.mark.parametrize('prior', [False, True])
def test_partial_local_journal_write_is_preserved_and_recovery_never_grants(journal, prior):
directory = Directory(failure='after')
if prior:
with pytest.raises(change.Refused):
change.transition(directory, journal, DIGEST, 'grant')
partial = journal.path / 'journal.next'
partial.write_bytes(b'{"incomplete')
partial.chmod(0o600)
directory.failure = None
result = change.transition(directory, journal, DIGEST, 'recover')
assert result['status'] == 'read_only_role_restored_verified'
assert not partial.exists()
assert b'{"incomplete' in [p.read_bytes() for p in journal.path.glob('history-*.json')]
def test_exclusive_lock_and_completed_grant_refuse_reentry(journal):
with pytest.raises(change.Refused, match='active'):
change.Journal(journal.path)
directory = Directory()
change.transition(directory, journal, DIGEST, 'grant')
directory.calls.clear()
with pytest.raises(change.Refused, match='rollback_for_completed'):
change.transition(directory, journal, DIGEST, 'recover')
with pytest.raises(change.Refused, match='not_replay'):
change.transition(directory, journal, DIGEST, 'grant')
assert not directory.calls
@pytest.mark.parametrize('defect', ['symlink', 'hardlink', 'public'])
def test_unsafe_existing_journal_refuses(journal, tmp_path, defect):
path = journal.path / 'journal.json'
if defect == 'symlink':
path.symlink_to(tmp_path / 'missing')
else:
path.write_text('{}')
path.chmod(0o600 if defect == 'hardlink' else 0o644)
if defect == 'hardlink':
os.link(path, tmp_path / 'alias')
with pytest.raises(change.Refused):
change.transition(Directory(), journal, DIGEST, 'grant')
@pytest.mark.parametrize('defect', ['no-terminal', 'wrong-digest', 'runtime-drift'])
def test_cli_refuses_before_private_input(monkeypatch, defect):
monkeypatch.setattr(change, 'proposal', lambda: ({'runtime': {}, 'scope': {}}, DIGEST))
monkeypatch.setattr(change.sys.stdin, 'isatty', lambda: defect != 'no-terminal')
monkeypatch.setattr(change.sys.stderr, 'isatty', lambda: True)
monkeypatch.setattr(change, 'observe_runtime', lambda: {'changed': True})
monkeypatch.setattr(change.getpass, 'getpass', lambda prompt: pytest.fail('private prompt'))
assert change.main(['--execute', '--expect-digest',
DIGEST + ('x' if defect == 'wrong-digest' else '')]) == 1
def test_recovery_allows_unready_peers_but_not_changed_runtime_identity(monkeypatch):
expected = {'authelia': {'uid': 'a', 'ready': True},
'lldap': {'uid': 'b', 'ready': True}, 'config': 'pinned'}
actual = copy.deepcopy(expected)
actual['authelia']['ready'] = False
monkeypatch.setattr(change, 'observe_runtime', lambda: copy.deepcopy(actual))
with pytest.raises(change.Refused, match='runtime_changed'):
change.verify_runtime(expected)
change.verify_runtime(expected, recovering=True)
actual['lldap']['uid'] = 'replacement'
with pytest.raises(change.Refused, match='runtime_changed'):
change.verify_runtime(expected, recovering=True)