"""Retired callers must refuse before credential discovery or external actions.""" import argparse import contextlib import importlib.util import io import json import os from pathlib import Path import shutil import subprocess import sys from unittest.mock import patch import pytest ROOT = Path(__file__).resolve().parents[1] def console(): path = ROOT / 'tools/security-bootstrap-console/security_bootstrap_console.py' spec = importlib.util.spec_from_file_location('retirement_console', path) module = importlib.util.module_from_spec(spec); sys.modules[spec.name] = module spec.loader.exec_module(module) return module @pytest.mark.parametrize('name', ['create-user.sh', 'break-glass.sh', 'dry-run-nonroot-user.sh']) def test_retired_shell_never_sources_credentials_or_calls_external_tools(tmp_path, name): calls = tmp_path / 'calls'; sourced = tmp_path / 'sourced'; private = tmp_path / 'private' for service, field in [('lldap', 'LLDAP_LDAP_USER_PASS'), ('breakglass', 'BREAKGLASS_PASSWORD')]: directory = private / service; directory.mkdir(parents=True, mode=0o700) (directory / 'secrets.env').write_text(f"{field}='synthetic-private-marker'\ntouch '{sourced}'\n") bin_dir = tmp_path / 'bin'; bin_dir.mkdir() for command in ['kubectl', 'curl']: executable = bin_dir / command executable.write_text(f"#!/bin/sh\nprintf called >> '{calls}'\nexit 1\n"); executable.chmod(0o700) env = dict(os.environ, PATH=str(bin_dir) + ':' + os.environ['PATH'], KUBECTL=str(bin_dir / 'kubectl'), LLDAP_ADMIN_PASS='synthetic-private-marker') env.pop('BASH_ENV', None) if name == 'create-user.sh': env.pop('LLDAP_ADMIN_PASS') args = ['test-user', 'test@example.test', 'Test User', 'http://127.0.0.1:1', str(private)] elif name == 'break-glass.sh': args = ['http://127.0.0.1:1', str(private)] else: args = ['test-user', 'test@example.test', 'Test User'] result = subprocess.run(['/bin/bash', '-x', str(ROOT / 'sso-mfa/k8s/lldap' / name), *args], env=env, cwd=tmp_path, capture_output=True, text=True, timeout=10) assert result.returncode == 2 assert not calls.exists() and not sourced.exists() assert 'synthetic-private-marker' not in result.stdout + result.stderr assert 'retired' in result.stderr @pytest.mark.parametrize('target,variable', [('security-bootstrap-onboarding-dry-run','SUBJECT'), ('security-bootstrap-onboarding-dry-run','EMAIL'), ('security-bootstrap-onboarding-dry-run','DISPLAY'), ('security-bootstrap-lifecycle-cleanup-dryrun-users','PATTERN')]) def test_make_retirement_never_interpolates_old_user_arguments(tmp_path, target, variable): # Harmless leaf replacement isolates the Make interpolation boundary. shutil.copyfile(ROOT / 'Makefile', tmp_path / 'Makefile') leaf = tmp_path / 'sso-mfa/k8s/lldap/dry-run-nonroot-user.sh'; leaf.parent.mkdir(parents=True) leaf.write_text('#!/bin/sh\nexit 2\n'); leaf.chmod(0o700) marker = tmp_path / 'command-executed' value = f'$(touch {marker})' # Escape Make's dollar processing so the old recipe would hand $(...) to the shell. result = subprocess.run(['make', '--no-print-directory', target, f'{variable}=$' + value], cwd=tmp_path, capture_output=True, text=True, timeout=10) assert result.returncode != 0 assert not marker.exists() @pytest.mark.parametrize('command', ['onboarding-dry-run', 'lifecycle-cleanup-dryrun-users', 'onboarding-dry-run-claims']) def test_console_retirement_returns_failure_without_delegation(command): module = console(); output = io.StringIO() with patch('subprocess.call') as call, patch('subprocess.run') as run, \ patch.object(module, 'load_metadata') as metadata, contextlib.redirect_stdout(output): code = module.main([command]) assert code == 2 call.assert_not_called(); run.assert_not_called(); metadata.assert_not_called() assert 'retired' in output.getvalue().lower() @pytest.mark.parametrize('factory,validator', [('lifecycle_flow_template','print_validate_lifecycle_flow'), ('onboarding_dry_run_template','print_validate_onboarding_dry_run')]) def test_blank_template_is_not_success_evidence(tmp_path, factory, validator): module = console(); record = getattr(module, factory)() assert not any(value is True for value in record.values()) path = tmp_path / 'evidence.json'; path.write_text(json.dumps(record)) output = io.StringIO() with contextlib.redirect_stdout(output): result = getattr(module, validator)(argparse.Namespace(evidence=str(path))) assert result != 0 def test_console_guidance_routes_to_owner_without_legacy_execution(): module = console(); output = io.StringIO() with contextlib.redirect_stdout(output): module.print_lifecycle_guide() assert 'User Engine' in output.getvalue() assert './create-user.sh' not in output.getvalue() assert 'safe secret extract' not in output.getvalue() payload = next(p for p in module.runbook_payloads({'openbao_initialized': True}) if p['name'] == 'User lifecycle dry-run (T06)') assert payload['state'] == 'retired' def test_direct_claims_compatibility_helper_never_infers_success(): module = console(); output = io.StringIO() with contextlib.redirect_stdout(output): result = module.print_dry_run_oidc_claims_verification('private-subject-marker', ['net-kingdom-admins']) assert result == 2 assert 'private-subject-marker' not in output.getvalue() assert 'net-kingdom-admins' not in output.getvalue()