Improve VPN status reporting and recovery

This commit is contained in:
2026-10-13 06:53:52 +03:00
parent fdb15ae462
commit 7a716b4c9c
188 changed files with 6953 additions and 1911 deletions
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Patch a disposable engine and execute its actual autofill JS without Qt/browser."""
import ast
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import tomllib
from types import SimpleNamespace
ROOT = Path(__file__).resolve().parents[1]
SMOKE = (ROOT/'tests/smoke.sh').read_text()
with tempfile.TemporaryDirectory(prefix='lemana-auth-retry-') as temporary:
folder = Path(temporary)
for name in ('webengine', 'authenticator'):
fixture = SMOKE.split('cat > "$fake_'+name+'" <<\'PY\'\n', 1)[1].split('\nPY\n', 1)[0]
(folder/(name+'.py')).write_text(fixture)
env = {key:value for key,value in os.environ.items() if not key.startswith(('LEMANA_VPN_', 'OPENCONNECT_LITE_'))}
env.update(HOME=temporary, LEMANA_VPN_WEBENGINE_PROCESS=str(folder/'webengine.py'),
LEMANA_VPN_AUTHENTICATOR=str(folder/'authenticator.py'), LEMANA_VPN_OC_PYTHON=sys.executable,
LEMANA_VPN_PATCH_BACKUP_DIR=str(folder/'backups'))
command = ['bash', str(ROOT/'bin/vpn-lemanapro.sh'), '--patch-only']
subprocess.run(command, env=env, check=True, capture_output=True)
patched = (folder/'webengine.py').read_text()
subprocess.run(command, env=env, check=True, capture_output=True)
assert patched == (folder/'webengine.py').read_text(), 'Patch must be idempotent'
compile(patched, '<patched engine>', 'exec')
tree = ast.parse(patched)
selectors = next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == 'get_selectors')
template = next(n.args[0] for n in ast.walk(tree) if isinstance(n, ast.Call)
and isinstance(n.func, ast.Attribute) and n.func.attr == 'setSourceCode')
profile = tomllib.loads((ROOT/'templates/openconnect-lite-config.toml').read_text())
url_pattern, rule_data = next(iter(profile['auto_fill_rules'].items()))
rules = [SimpleNamespace(selector=r['selector'], action=r.get('action'), fill=r.get('fill')) for r in rule_data]
messages, exits, delegated = [], [], []
class BasePage:
def javaScriptConsoleMessage(self, *args): delegated.append(args)
logger = SimpleNamespace(warning=lambda *args, **kwargs: messages.append(args))
fake_os = SimpleNamespace(environ={})
namespace = dict(os=fake_os, json=json, logger=logger, QWebEnginePage=BasePage,
QApplication=SimpleNamespace(instance=lambda: SimpleNamespace(exit=exits.append)),
credentials=SimpleNamespace(username='test-user', password='test-password', totp='123456'),
rules=rules, url_pattern=url_pattern,
js_regex_str=json.dumps('^'+url_pattern.replace('.', '\\.').replace('*', '.*')+'$'))
callback = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'LemanaVPNPage')
exec(compile(ast.Module(body=[selectors, callback], type_ignores=[]), '<autofill>', 'exec'), namespace)
page = namespace['LemanaVPNPage']()
page.javaScriptConsoleMessage(1, 'LEMANA_VPN_AUTH_REJECTED', 0, 'test')
assert exits == [77] and messages == [('LEMANA_VPN_AUTH_REJECTED',)]
fake_os.environ['LEMANA_VPN_AUTOFILL_CLICK'] = '0'
page.javaScriptConsoleMessage(1, 'LEMANA_VPN_AUTH_REJECTED', 0, 'test')
assert exits == [77] and len(delegated) == 1
scripts = {}
for name, click, attempt in [('auto', '1', 'attempt-one'), ('fresh', '1', 'attempt-two'), ('manual', '0', 'attempt-one')]:
fake_os.environ.update(LEMANA_VPN_AUTOFILL_CLICK=click, LEMANA_VPN_AUTH_ATTEMPT=attempt,
LEMANA_VPN_AUTOFILL_TOTP='0' if name == 'manual' else '1')
scripts[name] = eval(compile(ast.Expression(template), '<script>', 'eval'), namespace)
(folder/'scripts.json').write_text(json.dumps(scripts))
test_js = r'''
const assert = require('node:assert/strict');
const vm = require('node:vm');
const scripts = require(process.argv[2]);
function page(script, fields, storage = new Map(), url = 'https://employee.auth.lemanapro.ru/login') {
const clicks = [], messages = [], timers = [], elements = {};
for (const name of fields) elements[name] = {
type: name === 'password' ? 'password' : 'text', disabled: false,
getClientRects: () => [1], dispatchEvent: () => {}, value: ''
};
const button = {dispatchEvent: () => {}, click: () => clicks.push(1)};
const context = vm.createContext({location: {href: url}, Event: function() {},
console: {warn: message => messages.push(message)}, setTimeout: f => timers.push(f),
sessionStorage: {getItem: k => storage.get(k), setItem: (k,v) => storage.set(k,v)},
document: {querySelector: selector => {
if (selector.includes('input-error')) return elements.error || null;
if (selector === 'input#username') return elements.username || null;
if (selector === 'input#password') return elements.password || null;
if (selector.startsWith('input#otp')) return elements.totp || null;
if (selector.startsWith('input[type=submit]')) return button;
throw Error(selector);
}}
});
vm.runInContext(script, context);
return {clicks, messages, elements, context, storage, tick: () => timers.shift()?.()};
}
const login = page(scripts.auto, ['username','password']);
assert.equal(login.clicks.length, 1);
assert.equal(login.elements.password.value, 'test-password');
for(let i=0;i<5;i++) login.tick();
assert.equal(login.clicks.length, 1, 'No repeated click on a slow page');
const otp = page(scripts.auto, ['totp'], login.storage);
assert.equal(otp.clicks.length, 1, 'Password then OTP are separate stages');
const repeated = page(scripts.auto, ['username','password'], login.storage);
assert.equal(repeated.clicks.length, 0);
assert.deepEqual(repeated.messages, ['LEMANA_VPN_AUTH_REJECTED']);
const repeatedOtp = page(scripts.auto, ['totp'], login.storage);
assert.equal(repeatedOtp.clicks.length, 0);
assert.deepEqual(repeatedOtp.messages, ['LEMANA_VPN_AUTH_REJECTED']);
assert.equal(page(scripts.fresh, ['username','password'], login.storage).clicks.length, 1);
login.elements.error = {getClientRects: () => [1], textContent: 'Invalid credentials'};
login.tick(); login.tick();
assert.deepEqual(login.messages, ['LEMANA_VPN_AUTH_REJECTED']);
const manual = page(scripts.manual, ['username','password','totp'], login.storage);
manual.elements.error = {getClientRects: () => [1], textContent: 'Invalid code'};
manual.tick();
assert.equal(manual.elements.password.value, 'test-password');
assert.equal(manual.elements.totp.value, '');
assert.equal(manual.clicks.length, 0); assert.equal(manual.messages.length, 0);
const foreign = page(scripts.auto, ['username','password'], new Map(), 'https://unrelated.example/login');
assert.equal(foreign.elements.password.value, ''); assert.equal(foreign.clicks.length, 0);
const unavailable = page(scripts.auto, ['username','password'], {get: () => {throw Error('blocked')}});
assert.equal(unavailable.clicks.length, 0);
assert.deepEqual(unavailable.messages, ['LEMANA_VPN_AUTH_REJECTED']);
const staged = page(scripts.auto, ['username']);
staged.elements.password = {type:'password', getClientRects:()=>[1], dispatchEvent:()=>{}, value:''};
staged.tick();
assert.equal(staged.clicks.length, 2, 'Username then password in the same document');
console.log('PASS autofill: one submit per stage, reload/error terminal, fresh attempt, manual, URL guard, unavailable storage');
'''
(folder/'checks.cjs').write_text(test_js)
subprocess.run(['node', str(folder/'checks.cjs'), str(folder/'scripts.json')], check=True)
print('PASS engine: isolated patch idempotence, Python syntax, rejection callback terminates only automatic authentication')
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Persisted auth selection with fake dependencies and no live connection."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[1]
CLI = ROOT / "bin/vpn-lemanapro.sh"
with tempfile.TemporaryDirectory(prefix="lemana-auth-") as temporary:
home = Path(temporary)
config = home / "config"
config.mkdir()
saved = config / "env"
custom = 'LEMANA_VPN_USERNAME="fixture-user"\nLEMANA_VPN_BW_ITEM="Corporate & VPN"\nLEMANA_VPN_LOG_MAX_BYTES="4096"\nLEMANA_VPN_USE_TOUCHID="0"\n'
saved.write_text(custom + 'LEMANA_VPN_MODE="manual-keychain"\n')
fake = home / "fake"
fake.mkdir()
fake.joinpath("security").write_text('''#!/bin/sh
while [ "$#" -gt 0 ]; do
if [ "$1" = -w ]; then echo forbidden-secret-read >> "$HOME/side-effects"; exit 1; fi
if [ "$1" = -a ]; then shift; account="$1"; fi
shift
done
case "${TEST_CREDENTIALS:-all}:$account" in none:*|password:totp/*) exit 1 ;; *) exit 0 ;; esac
''')
for name in ("sudo", "openconnect", "bw"):
fake.joinpath(name).write_text('#!/bin/sh\necho unexpected-execution >> "$HOME/side-effects"\nexit 1\n')
for path in fake.iterdir():
path.chmod(0o755)
env = {key: value for key, value in os.environ.items() if not key.startswith(("LEMANA_VPN_", "OPENCONNECT_LITE_"))}
env.update(HOME=temporary, PATH=str(fake) + ":" + os.environ["PATH"],
LEMANA_VPN_CONFIG_DIR=str(config), OPENCONNECT_LITE_CONFIG_DIR=str(home / "sso"),
LEMANA_VPN_STATUS_DIR=str(home / "state"), LEMANA_VPN_OC_VENV=str(home / "no-venv"),
LEMANA_VPN_OC_BIN=str(fake / "openconnect"), LEMANA_VPN_APP_DIR=str(home / "no-app"),
LEMANA_VPN_DNS_CLEANUP=str(fake / "sudo"))
def run(*args, extra=None, success=True):
result = subprocess.run(["bash", str(CLI), *args], env=dict(env, **(extra or {})),
text=True, capture_output=True, timeout=10)
if success:
assert result.returncode == 0, result.stderr
else:
assert result.returncode != 0, result.stdout
return result
def status(extra=None):
return json.loads(run("--status", "--json", extra=extra).stdout)["modules"]
for choice, mode, source, preferred in (
("keychain", "keychain-auto", "keychain", "keychain"),
("manual", "manual-keychain", "keychain", "keychain"),
("bitwarden", "bitwarden-auto", "bitwarden", "bitwarden"),
("manual", "manual-keychain", "keychain", "bitwarden"),
("auto", "bitwarden-auto", "bitwarden", "bitwarden"),
("keychain", "keychain-auto", "keychain", "keychain"),
):
run("--set-auth=" + choice)
modules = status()
assert modules["mode"]["id"] == mode
assert modules["auth"]["source"] == source
assert modules["auth"]["auto_source"] == preferred
assert modules["auth"]["mode"] == ("manual" if mode == "manual-keychain" else "auto")
assert modules["auth"]["ready"]
assert custom.rstrip() in saved.read_text()
assert saved.stat().st_mode & 0o777 == 0o600
assert not list(config.glob(".auth-env-*"))
assert not (home / "side-effects").exists()
assert not (home / "state").exists()
print("PASS saved selection: manual/automatic sources, remembered auto source, settings preserved, no connection or secret reads")
before = saved.read_text()
run("--set-auth=invalid", success=False)
assert saved.read_text() == before
fake.joinpath("python3").write_text('#!/bin/sh\nexit 1\n')
fake.joinpath("python3").chmod(0o755)
run("--set-auth=manual", success=False)
assert saved.read_text() == before
fake.joinpath("python3").unlink()
print("PASS failed/invalid save leaves active settings unchanged")
for credentials, hint in (("none", "--configure-login"), ("password", "--import-yandex-key")):
modules = status({"TEST_CREDENTIALS": credentials})
assert not modules["auth"]["ready"] and hint in modules["auth"]["setup_hint"]
failure = run("--json", extra={"TEST_CREDENTIALS": credentials}, success=False)
assert hint in failure.stdout
assert not (home / "side-effects").exists() and not (home / "state").exists()
print("PASS incomplete local automatic setup blocks client launch with correct guidance")
saved.write_text(custom + 'LEMANA_VPN_MODE="manual-keychain"\nLEMANA_VPN_LOCAL_OTP="1"\n')
assert status()["mode"]["id"] == "keychain-auto"
manual = json.loads(run("--manual", "--status", "--json").stdout)["modules"]
assert manual["mode"]["id"] == "manual-keychain" and not manual["local_otp"]
print("PASS approved legacy-local migration and explicit manual override")
print("auth settings checks ok")
+181
View File
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""Exercise installer rendering in temporary directories; never run installer main."""
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
import tomllib
ROOT = Path(__file__).resolve().parents[1]
INSTALLER = Path(sys.argv[1]) if len(sys.argv) > 1 else ROOT / "install.sh"
SOURCE = INSTALLER.read_text().rsplit('\nmain "$@"', 1)[0]
KEYS = ("LEMANA_VPN_MODE", "LEMANA_VPN_USERNAME", "LEMANA_VPN_BW_ITEM", "LEMANA_VPN_USE_TOUCHID")
def setup(path):
env = {key: value for key, value in os.environ.items()
if not key.startswith(("LEMANA_VPN_", "OPENCONNECT_LITE_"))}
env.update(HOME=str(path), LEMANA_VPN_CONFIG_DIR=str(path / "settings"),
OPENCONNECT_LITE_CONFIG_DIR=str(path / "openconnect"),
TEST_STAGE=str(path / "stage"))
for directory in ("settings", "openconnect", "stage"):
(path / directory).mkdir()
return env
def render(env, *args):
command = SOURCE + '''
# Only configuration writes are exercised, with repository-local templates.
download_file() { cp "$TEST_ROOT/$1" "$2"; }
preserve_existing_username
install_config "$TEST_STAGE"
set -a
. "$CONFIG_DIR/env"
python3 - <<'PYRESULT'
import json, os
print("RESULT=" + json.dumps({key: os.environ[key] for key in (
"LEMANA_VPN_MODE", "LEMANA_VPN_USERNAME", "LEMANA_VPN_BW_ITEM", "LEMANA_VPN_USE_TOUCHID")}))
PYRESULT
'''
result = subprocess.run(["/bin/sh", "-c", command, str(ROOT / "install.sh"), *args],
env=dict(env, TEST_ROOT=str(ROOT)), capture_output=True, text=True, timeout=10)
assert result.returncode == 0, result.stderr or result.stdout
return json.loads(next(line[7:] for line in result.stdout.splitlines() if line.startswith("RESULT=")))
def mode_from_environment(path, env):
settings = render(dict(env, LEMANA_VPN_MODE="bitwarden-auto"))
assert settings["LEMANA_VPN_MODE"] == "bitwarden-auto", settings
def literal_settings(path, env):
sentinel = path / "must-not-exist"
special = 'Sales/VPN & "quotes" \\ $HOME `touch ' + str(sentinel) + '` $(touch ' + str(sentinel) + ') {{USERNAME}}'
settings = render(env, "--mode", "bitwarden-auto", "--username", special, "--bw-item", special)
assert settings["LEMANA_VPN_USERNAME"] == special, settings
assert settings["LEMANA_VPN_BW_ITEM"] == special, settings
assert not sentinel.exists(), "configuration executed a command substitution"
text = (path / "openconnect/config.toml").read_text()
username = next(line.split("=", 1)[1].strip() for line in text.splitlines() if line.startswith("username ="))
assert json.loads(username) == special, text
again = render(env)
assert again == settings, (settings, again)
assert not sentinel.exists()
def update_preserves_settings(path, env):
config = path / "settings/env"
custom = '# User runtime options\nLEMANA_VPN_LOG_MAX_BYTES="12345"\nLEMANA_VPN_CONNECT_WAIT_SECONDS="37"\n'
config.write_text('LEMANA_VPN_MODE="bitwarden-auto"\nLEMANA_VPN_USERNAME="saved-user"\nLEMANA_VPN_BW_ITEM="Saved/VPN & more" # custom item\nLEMANA_VPN_USE_TOUCHID="0"\n' + custom)
original_toml = ('# User SSO profile\n[default_profile]\naddress = "custom.example"\n'
'username = "leave-profile-field"\n\n[credentials] # saved login\nusername = "old-user"\n'
'\n[[auto_fill_rules."https://custom.example/*"]]\nselector = "#custom"\nfill = "username"\n')
(path / "openconnect/config.toml").write_text(original_toml)
settings = render(env)
assert settings == dict(zip(KEYS, ("bitwarden-auto", "saved-user", "Saved/VPN & more", "0"))), settings
assert custom.rstrip() in config.read_text()
assert (path / "openconnect/config.toml").read_text() == original_toml.replace('username = "old-user"', 'username = "saved-user"')
first = config.read_text()
assert render(env) == settings
assert config.read_text() == first, "a second update changed unowned settings"
assert config.stat().st_mode & 0o777 == 0o600
assert (path / "openconnect/config.toml").stat().st_mode & 0o777 == 0o600
def local_auto_migration(path, env):
config = path / "settings/env"
legacy = 'LEMANA_VPN_MODE="manual-keychain"\nLEMANA_VPN_LOCAL_OTP="1"\nLEMANA_VPN_USERNAME="saved"\n'
config.write_text(legacy)
assert render(env)["LEMANA_VPN_MODE"] == "keychain-auto"
assert 'LEMANA_VPN_AUTO_SOURCE="keychain"' in config.read_text()
assert render(env)["LEMANA_VPN_MODE"] == "keychain-auto"
assert render(env, "--mode", "manual-keychain")["LEMANA_VPN_MODE"] == "manual-keychain"
assert 'LEMANA_VPN_LOCAL_OTP="0"' in config.read_text()
config.write_text(legacy)
assert render(dict(env, LEMANA_VPN_MODE="manual-keychain"))["LEMANA_VPN_MODE"] == "manual-keychain"
def precedence(path, env):
(path / "settings/env").write_text('LEMANA_VPN_MODE="manual-keychain"\nLEMANA_VPN_USERNAME="saved"\nLEMANA_VPN_BW_ITEM="saved"\nLEMANA_VPN_USE_TOUCHID="0"\n')
invocation = dict(env, LEMANA_VPN_MODE="bitwarden-auto", LEMANA_VPN_USERNAME="environment",
LEMANA_VPN_BW_ITEM="environment", LEMANA_VPN_USE_TOUCHID="1")
settings = render(invocation)
assert settings == dict(zip(KEYS, ("bitwarden-auto", "environment", "environment", "1"))), settings
settings = render(invocation, "--mode", "manual-keychain", "--username", "flag", "--bw-item", "flag", "--without-touchid")
assert settings == dict(zip(KEYS, ("manual-keychain", "flag", "flag", "0"))), settings
def legacy_environment_precedence(path, env):
for saved_auto in (False, True):
saved = ("bitwarden-auto", "auto", "bitwarden", "1") if saved_auto else ("manual-keychain", "saved-login", "keychain", "0")
overrides = (("CREDENTIAL_SOURCE", "keychain"), ("AUTH_PROFILE", "manual"), ("USE_BITWARDEN", "0")) if saved_auto else (("CREDENTIAL_SOURCE", "bitwarden"), ("AUTH_PROFILE", "auto"), ("USE_BITWARDEN", "1"))
for key, value in overrides:
(path / "settings/env").write_text("".join("LEMANA_VPN_" + name + "=" + json.dumps(setting) + "\n" for name, setting in zip(("MODE", "AUTH_PROFILE", "CREDENTIAL_SOURCE", "USE_BITWARDEN"), saved)))
settings = render(dict(env, **{"LEMANA_VPN_" + key: value}))
expected = "manual-keychain" if saved_auto else "bitwarden-auto"
assert settings["LEMANA_VPN_MODE"] == expected, (key, value, settings)
def safe_saved_read(path, env):
sentinel = path / "must-not-exist"
(path / "settings/env").write_text('LEMANA_VPN_MODE="manual-keychain"\nLEMANA_VPN_USERNAME="$(touch ' + str(sentinel) + ')"\n')
settings = render(env)
assert not sentinel.exists(), "reading saved settings executed shell code"
assert settings["LEMANA_VPN_USERNAME"] == '$(touch ' + str(sentinel) + ')'
def missing_credentials_section(path, env):
(path / "openconnect/config.toml").write_text('[default_profile]\nusername = "profile-user"\n')
render(env, "--username", "new-user")
text = (path / "openconnect/config.toml").read_text()
assert text == '[default_profile]\nusername = "profile-user"\n\n[credentials]\nusername = "new-user"\n', text
def empty_saved_username(path, env):
(path / "settings/env").write_text('LEMANA_VPN_USERNAME=""\n')
(path / "openconnect/config.toml").write_text('[credentials]\nusername = "toml-user"\n')
assert render(env)["LEMANA_VPN_USERNAME"] == "toml-user"
assert render(dict(env, LEMANA_VPN_USERNAME=""))["LEMANA_VPN_USERNAME"] == ""
def toml_scalar_forms(path, env):
for header, key in (("[ credentials ]", "username"), ('["credentials"]', '"username"'), ("[ 'credentials' ]", "'username'")):
(path / "settings/env").write_text('LEMANA_VPN_USERNAME=""\n')
original = header + ' # custom login\n' + key + ' = "toml-user"\n[default_profile]\naddress = "custom.example"\n'
(path / "openconnect/config.toml").write_text(original)
assert render(env)["LEMANA_VPN_USERNAME"] == "toml-user"
parsed = tomllib.loads((path / "openconnect/config.toml").read_text())
assert parsed == {"credentials": {"username": "toml-user"}, "default_profile": {"address": "custom.example"}}, parsed
render(env, "--username", "new-user")
parsed = tomllib.loads((path / "openconnect/config.toml").read_text())
assert parsed["credentials"]["username"] == "new-user", parsed
(path / "openconnect/config.toml").write_text('[ "credentials" ]')
render(env, "--username", "end-of-file")
assert tomllib.loads((path / "openconnect/config.toml").read_text()) == {"credentials": {"username": "end-of-file"}}
def fresh_help_without_python(path, env):
# Keep only the help command available; Python must not be needed yet.
(path / "stage/cat").symlink_to("/bin/cat")
result = subprocess.run(["/bin/sh", "-c", SOURCE, str(ROOT / "install.sh"), "--help"],
env=dict(env, PATH=str(path / "stage")), text=True, capture_output=True, timeout=10)
assert result.returncode == 0 and "Usage:" in result.stdout, result.stderr
failures = []
for check in (mode_from_environment, literal_settings, update_preserves_settings, local_auto_migration, precedence,
legacy_environment_precedence, safe_saved_read, missing_credentials_section,
empty_saved_username, toml_scalar_forms, fresh_help_without_python):
with tempfile.TemporaryDirectory(prefix="lemana-config-") as temporary:
path = Path(temporary)
try:
check(path, setup(path))
print("PASS", check.__name__)
except (AssertionError, subprocess.TimeoutExpired) as error:
failures.append(check.__name__)
print("FAIL", check.__name__, str(error))
if failures:
raise SystemExit(1)
print("configuration checks passed")
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Actual CLI parsing/retry functions with isolated transcripts and fake clients."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[1]
SOURCE = Path(os.environ.get('TEST_CLI_SOURCE', ROOT/'bin/vpn-lemanapro.sh')).read_text()
def function(name):
start = SOURCE.index(name + '() {')
return SOURCE[start:SOURCE.index('\n}\n', start) + 3]
with tempfile.TemporaryDirectory(prefix='lemana-runtime-') as temporary:
folder = Path(temporary)
env = dict(os.environ, HOME=temporary, TEST_ROOT=temporary, TZ='Europe/Moscow')
functions = ['_emit', '_write_status', '_filter_output']
functions += [name for name in ('_parse_expiry', '_valid_ipv4', '_publish_connection') if name+'() {' in SOURCE]
setup = '\n'.join(function(name) for name in functions) + r'''
set -euo pipefail
JSON_MODE=true; DEBUG=false; MODE=keychain-auto
STATUS_DIR="$TEST_ROOT/state"; STATUS_FILE="$STATUS_DIR/status.json"
ATTEMPT_DIR="$TEST_ROOT/attempt"; mkdir -p "$ATTEMPT_DIR"
_log_connection_line() { :; }
_stop_connect_progress() { :; }
scutil() { :; }
'''
def transcript(lines):
result = subprocess.run(['bash', '-c', setup + '\n_filter_output'], input=lines,
env=env, capture_output=True, text=True, check=True, timeout=10)
return [json.loads(line) for line in result.stdout.splitlines()]
events = transcript('Configured as 10.2.3.4, with SSL connected\n')
assert len(events) == 1 and events[0]['event'] == 'connected', events
assert events[0]['ip'] == '10.2.3.4' and not events[0].get('expires')
assert json.loads((folder/'state/status.json').read_text())['state'] == 'connected'
assert transcript('Configured as 999.2.3.4\nConfigured as 1.2.3.4.5\n') == []
for stamp, expected in [
('Tue Jan 01 02:00:00 2030', '2029-12-31T23:00:00Z'),
('Tue, 01 Jan 2030 02:00:00 MSK', '2029-12-31T23:00:00Z'),
('Tue, 01 Jan 2030 02:00:00 +07', '2029-12-31T19:00:00Z'),
('Tue, 01 Jan 2030 02:00:00 -0330', '2030-01-01T05:30:00Z'),
('Tue, 01 Jan 2030 02:00:00 +05:30', '2029-12-31T20:30:00Z'),
]:
for before in (True, False):
ip = 'Configured as 10.2.3.4\n'
expiry = 'Session authentication will expire at ' + stamp + '\n'
events = transcript((expiry+ip if before else ip+expiry) + expiry + 'Session authentication will expire at invalid\n')
assert events[0]['event'] == 'connected' and events[-1]['expires'] == expected, events
assert len({item['started_at'] for item in events}) == 1
assert json.loads((folder/'state/status.json').read_text())['expires'] == expected
assert transcript('Session authentication will expire at Tue Jan 01 02:00:00 2030\n') == []
print('PASS status: IP alone, valid IPv4, expiry order/formats/timezones, invalid metadata cannot erase valid timer')
status_functions = '\n'.join(function(name) for name in ('_check_status', '_json_get'))
status_setup = setup + '\n' + status_functions + r'''
kill() { return "${TEST_PID_DEAD:-0}"; }
_module_status_json() { printf '{}'; }
_module_status_human() { :; }
date() {
if [[ "$*" == '+%s' ]]; then printf '1893456000\n'; else /bin/date "$@"; fi
}
'''
def status(value, **extra):
(folder/'state/status.json').write_text(json.dumps(dict(pid=123, **value)))
result = subprocess.run(['bash','-c',status_setup+'\n_check_status'], env=dict(env, **extra),
text=True, capture_output=True, check=True)
return json.loads(result.stdout)
for state in ('connecting','reconnecting','disconnecting'):
assert status(dict(state=state))['state'] == state
assert status(dict(state=state), TEST_PID_DEAD='1')['state'] == 'disconnected'
assert status(dict(state='connected', ip='10.2.3.4', expires='2030-01-01T02:00:00Z'))['remaining_sec'] == 7200
assert status(dict(state='connected', ip='10.2.3.4', expires='2029-12-31T23:00:00Z'))['remaining_sec'] == 0
assert status(dict(state='connected', ip='10.2.3.4', expires=''))['expires'] == ''
result = subprocess.run(['bash','-c',status_setup+'\nJSON_MODE=false; _check_status'], env=env,
text=True, capture_output=True, check=True)
assert 'срок неизвестен' in result.stdout
print('PASS status query: live phases, dead PID, UTC countdown outside UTC, expiry clamped, unknown duration')
runtime = '\n'.join(function(name) for name in ('_run_connections', '_recover_for_retry'))
client = folder/'client'
client.write_text('''#!/bin/sh
count=0
[ ! -f "$TEST_ROOT/launches" ] || read -r count < "$TEST_ROOT/launches"
count=$((count+1)); echo "$count" > "$TEST_ROOT/launches"
for last_arg do :; done
case "$last_arg" in *" --vpnc $LEMANA_VPN_AUTH_ATTEMPT") ;; *) exit 91 ;; esac
printf '%s\\n' "$LEMANA_VPN_AUTH_ATTEMPT" >> "$TEST_ROOT/attempts"
case "$TEST_CASE:$count" in
auth:*) echo LEMANA_VPN_AUTH_REJECTED; exit 2 ;;
connected:1) echo 'Configured as 10.2.3.4'; exit 0 ;;
connected:2) exit 2 ;;
*) exit 7 ;;
esac
''')
client.chmod(0o755)
setup += r'''
_start_connect_progress() { :; }
_show_connection_log_tail() { :; }
sleep() { echo "$1" >> "$TEST_ROOT/delays"; }
sudo() {
printf '%s\n' "$*" >> "$TEST_ROOT/helper-calls"
printf '%s\n' "${TEST_RECOVERY:-restored}"
}
DNS_CLEANUP=/fake/helper; SESSION_ID=11111111-2222-3333-4444-555555555555
OC_BIN="$TEST_ROOT/client"; OC_RUNTIME_XDG_CONFIG_HOME="$HOME"
display_mode=hidden; log_level=''; autofill_debug=0; autofill_disable=0; autofill_click=1; autofill_totp=1
dns_hook='/fake/helper --vpnc'; MODE="${TEST_MODE:-keychain-auto}"
'''
cases = [('crash', 'keychain-auto', 'restored', 6, [5,10,20,40,60]),
('crash', 'bitwarden-auto', 'restored', 6, [5,10,20,40,60]),
('crash', 'manual-keychain', 'restored', 1, []),
('connected', 'keychain-auto', 'restored', 2, [5]),
('auth', 'keychain-auto', 'restored', 1, []),
('crash', 'keychain-auto', 'cancelled', 1, []),
('crash', 'keychain-auto', 'error', 1, []),
('crash', 'keychain-auto', 'active', 1, [1]*29)]
for case, mode, recovery, launches, delays in cases:
for name in ('launches','delays','helper-calls','attempts'):
(folder/name).unlink(missing_ok=True)
result = subprocess.run(['bash','-c', setup+'\n'+runtime+'\n_run_connections'],
env=dict(env, TEST_CASE=case, TEST_MODE=mode, TEST_RECOVERY=recovery),
text=True, capture_output=True, timeout=10)
assert int((folder/'launches').read_text()) == launches, (case,mode,recovery,result.stdout,result.stderr)
attempts = (folder/'attempts').read_text().splitlines()
assert len(attempts) == launches and len(set(attempts)) == launches
actual = [int(s) for s in (folder/'delays').read_text().splitlines()] if (folder/'delays').exists() else []
assert actual == delays, (case,mode,recovery,actual)
events = [json.loads(line) for line in result.stdout.splitlines()]
if case == 'connected':
assert [e['attempt_id'] for e in events if e['event'] == 'connected'] == [attempts[0]]
if case == 'auth': assert any(e['event']=='auth_rejected' for e in events)
if recovery in ('error','active'): assert events[-1]['event']=='error'
if recovery == 'cancelled': assert result.returncode == 0
print('PASS retry: both automatic modes, manual/cancel/auth terminal, bounded backoff, DNS failures block new launches')
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""DNS command boundary with fake sudo/helper; never touches system DNS."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[1]
CLI = ROOT / "bin/vpn-lemanapro.sh"
with tempfile.TemporaryDirectory(prefix="lemana-dns-cli-") as temporary:
home = Path(temporary)
fake = home / "bin"
fake.mkdir()
helper = home / "DNS helper's script"
for name in ("security", "openconnect", "bw", "networksetup"):
(fake / name).write_text('#!/bin/sh\necho forbidden >> "$HOME/forbidden"\nexit 1\n')
# Status may check Keychain presence, but repair must not even read credentials.
(fake / "security").write_text('#!/bin/sh\necho keychain >> "$HOME/keychain-read"\nexit 1\n')
(fake / "sudo").write_text('''#!/bin/sh
[ "$1" = -n ] || exit 90
shift
printf '%s\n' "$@" > "$HOME/sudo-args"
[ "${TEST_SUDO_FAIL:-0}" = 0 ] || exit 1
exec "$@"
''')
for path in fake.iterdir():
path.chmod(0o755)
env = {k: v for k, v in os.environ.items() if not k.startswith(("LEMANA_VPN_", "OPENCONNECT_LITE_"))}
env.update(HOME=temporary, PATH=str(fake) + ":" + os.environ["PATH"],
LEMANA_VPN_CONFIG_DIR=str(home / "config"), LEMANA_VPN_STATUS_DIR=str(home / "status"),
LEMANA_VPN_OC_VENV=str(home / "venv"), LEMANA_VPN_OC_BIN=str(fake / "openconnect"),
LEMANA_VPN_DNS_CLEANUP=str(helper))
def repair(**extra):
result = subprocess.run(["bash", str(CLI), "--repair-dns", "--json"],
env=dict(env, **extra), text=True, capture_output=True, timeout=10)
payload = json.loads(result.stdout)
assert payload["event"] == "dns_recovery"
assert not (home / "forbidden").exists()
assert not (home / "keychain-read").exists()
assert not (home / "status").exists()
return result.returncode, payload["result"]
assert repair() == (1, "error")
helper.write_text('#!/bin/sh\necho old-helper-ran >> "$HOME/forbidden"\n')
helper.chmod(0o755)
assert repair() == (1, "error")
assert not (home / "sudo-args").exists(), "Legacy helper must not be executed"
helper.write_text('''#!/bin/sh
# LEMANA_DNS_PROTOCOL=2
[ "$#" = 1 ] && [ "$1" = --recover ] || exit 91
printf '%s\n' "${TEST_DNS_RESULT:-unchanged}"
exit "${TEST_DNS_EXIT:-0}"
''')
for value in ("restored", "unchanged", "no_snapshot", "active"):
assert repair(TEST_DNS_RESULT=value) == (0, value)
assert (home / "sudo-args").read_text().splitlines() == [str(helper), "--recover"]
for extra in ({"TEST_DNS_RESULT": "not-json\nwrong"}, {"TEST_DNS_EXIT": "1"}, {"TEST_SUDO_FAIL": "1"}):
assert repair(**extra) == (1, "error")
source = CLI.read_text()
def function(name):
start = source.index(name + "() {")
return source[start:source.index("\n}\n", start) + 3]
cleanup = "\n".join(function(name) for name in ("_emit", "_dns_recovery_supported", "_dns_cleanup"))
for client_exit, recovery_exit in ((0, 0), (7, 0), (7, 1)):
ended = subprocess.run(["bash", "-c", cleanup + '\nJSON_MODE=true\n_dns_cleanup "$TEST_CLIENT_EXIT"'],
env=dict(env, DNS_CLEANUP=str(helper), TEST_CLIENT_EXIT=str(client_exit),
TEST_DNS_EXIT=str(recovery_exit)), text=True, capture_output=True, timeout=10)
assert ended.returncode == client_exit, ended.stderr
events = [json.loads(line) for line in ended.stdout.splitlines()]
assert events[0]["event"] == "dns_cleanup"
assert events[-1] == {"event": "dns_recovery", "result": "error" if recovery_exit else "unchanged"}
helper.write_text('''#!/bin/sh
# LEMANA_DNS_PROTOCOL=2
count=0
[ ! -f "$HOME/recovery-count" ] || read -r count < "$HOME/recovery-count"
count=$((count + 1))
printf '%s\\n' "$count" > "$HOME/recovery-count"
if [ "$count" -le "$TEST_ACTIVE_COUNT" ]; then
printf 'active\\n'
else
touch "$HOME/dns-restored"
printf 'restored\\n'
fi
''')
(fake / "sleep").write_text('#!/bin/sh\nexit 0\n')
(fake / "sleep").chmod(0o755)
for active_count in (3, 100):
(home / "recovery-count").unlink(missing_ok=True)
(home / "dns-restored").unlink(missing_ok=True)
ended = subprocess.run(["bash", "-c", cleanup + '\nJSON_MODE=true\n_dns_cleanup 130'],
env=dict(env, DNS_CLEANUP=str(helper), TEST_ACTIVE_COUNT=str(active_count)),
text=True, capture_output=True, timeout=10)
assert ended.returncode == 130, ended.stderr
events = [json.loads(line) for line in ended.stdout.splitlines()]
assert events[-1] == {"event": "dns_recovery", "result": "restored" if active_count == 3 else "active"}
assert (home / "dns-restored").exists() == (active_count == 3)
assert int((home / "recovery-count").read_text()) == (4 if active_count == 3 else 30)
print("PASS disconnect recovery: waits for exiting VPN children, preserves exit code, bounds active-session retries")
status = subprocess.run(["bash", str(CLI), "--status", "--json"], env=env,
text=True, capture_output=True, check=True, timeout=10)
assert json.loads(status.stdout)["modules"]["dns_cleanup"]["recovery_supported"]
assert not (home / "forbidden").exists()
# Exercise the actual OpenConnect shell command with a path containing quotes.
hook_line = next(line for line in CLI.read_text().splitlines() if line.startswith("dns_hook="))
helper.write_text('#!/bin/sh\nprintf "%s\\n" "$@" > "$HOME/hook-args"\n')
subprocess.run(["bash", "-c", hook_line + '\n/bin/sh -c "$dns_hook"'],
env=dict(env, DNS_CLEANUP=str(helper), SESSION_ID='11111111-2222-3333-4444-555555555555'), check=True, timeout=10)
assert (home / "hook-args").read_text().splitlines() == ["--vpnc", "11111111-2222-3333-4444-555555555555"]
print("PASS DNS CLI: noninteractive recovery, fixed JSON, no connection/credentials, legacy capability guard")
(home / 'keychain-read').unlink(missing_ok=True)
(home / 'sudo-args').unlink(missing_ok=True)
token = '11111111-2222-3333-4444-555555555555'
def stop(**extra):
result = subprocess.run(['bash', str(CLI), '--stop-session=' + token, '--json'],
env=dict(env, **extra), text=True, capture_output=True, timeout=10)
assert not (home/'keychain-read').exists() and not (home/'forbidden').exists()
assert not (home/'status').exists()
return result.returncode, json.loads(result.stdout)
assert stop() == (1, {'event': 'session_stop', 'result': 'error'})
assert not (home/'sudo-args').exists(), 'An old helper must never receive --stop'
helper.write_text('''#!/bin/sh
# LEMANA_SESSION_PROTOCOL=1
[ "$#" = 2 ] && [ "$1" = --stop ] || exit 91
printf '%s\\n' "${TEST_STOP_RESULT:-stopping}"
exit "${TEST_STOP_EXIT:-0}"
''')
assert stop() == (0, {'event': 'session_stop', 'result': 'stopping'})
assert (home/'sudo-args').read_text().splitlines() == [str(helper), '--stop', token]
for extra in ({'TEST_STOP_RESULT': 'wrong'}, {'TEST_STOP_EXIT': '7'}, {'TEST_SUDO_FAIL': '1'}):
assert stop(**extra) == (1, {'event': 'session_stop', 'result': 'error'})
for invalid in ('', '../../etc/passwd', token + ';echo bad'):
result = subprocess.run(['bash', str(CLI), '--stop-session=' + invalid, '--json'], env=env,
text=True, capture_output=True, timeout=10)
assert result.returncode == 2
print('PASS stop CLI: exact session forwarded, no credentials/connect, unsupported helper and failures rejected')
attempt = '22222222-2222-3333-4444-555555555555'
restart_args = ['--restart-session='+token, '--restart-attempt='+attempt]
def restart(args=restart_args, **extra):
result = subprocess.run(['bash', str(CLI), *args], env=dict(env, **extra),
text=True, capture_output=True, timeout=10)
assert not (home/'keychain-read').exists() and not (home/'forbidden').exists()
assert not (home/'status').exists()
return result
(home/'sudo-args').unlink()
assert restart().returncode != 0 and not (home/'sudo-args').exists()
helper.write_text('''#!/bin/sh
# LEMANA_SESSION_PROTOCOL=1
# LEMANA_RECONNECT_PROTOCOL=1
[ "$#" = 3 ] && [ "$1" = --restart ] || exit 91
printf '%s\\n' "${TEST_RESTART_RESULT:-stopping}"
''')
for value in ('stopping','stale','cancelled'):
result = restart(TEST_RESTART_RESULT=value)
assert result.returncode == 0 and result.stdout.strip() == value
assert (home/'sudo-args').read_text().splitlines() == [str(helper), '--restart', token, attempt]
assert restart(TEST_RESTART_RESULT='unexpected').returncode != 0
assert restart(TEST_SUDO_FAIL='1').returncode != 0
for args in ([restart_args[0]], [restart_args[1]], [restart_args[0], '--restart-attempt=invalid']):
assert restart(args).returncode == 2
print('PASS restart CLI: exact session/attempt pair, capability gate, missing/invalid pair and helper failure rejected')
+362
View File
@@ -0,0 +1,362 @@
#!/usr/bin/env python3
"""Run the actual privileged shell flow against isolated fake macOS commands."""
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
import tempfile
import time
ROOT = Path(__file__).resolve().parents[1]
SOURCE = ROOT / 'libexec/lemana-vpn-dns-cleanup'
FAKE = r'''
import json, os, pathlib, sys, time
base = pathlib.Path(__file__).resolve().parent
path = base / 'system.json'
state = json.loads(path.read_text())
cmd = pathlib.Path(sys.argv[0]).name
if cmd == 'fake.py': cmd = sys.argv.pop(1)
args = sys.argv[1:]
def save(): path.write_text(json.dumps(state))
def event(text):
with (base/'events').open('a') as output: output.write(text+'\n')
if cmd == 'id': print('0')
elif cmd == 'stat':
info = pathlib.Path(args[-1]).stat()
print('0:'+oct(info.st_mode & 0o777)[2:])
elif cmd == 'sysctl': print(state['boot'])
elif cmd == 'ps':
if state.get('ps_fail'): sys.exit(1)
if state.get('ps_empty'): sys.exit(0)
pid = os.environ['TEST_SHELL_PID']
parent = state['owner'] if os.environ.get('TEST_HOOK') else (200 if os.environ.get('TEST_BEGIN') else 1)
rows = [(1,0,'launchd'), (pid,parent,'/bin/sh -s --')]
if state.get('vpn_active') or os.environ.get('TEST_HOOK'):
rows += [(state['owner'],300,'/opt/homebrew/bin/openconnect --cookie-on-stdin'),
(300,200,'python /fake/openconnect-lite'),
(200,1,'/bin/bash /fake/vpn-lemanapro.sh --json'),
(210,200,'/bin/bash /fake/vpn-lemanapro.sh --json'),
(211,200,'/bin/bash /fake/vpn-lemanapro.sh --json')]
if state.get('reused_pid'): rows.append((400,1,'/usr/bin/sleep 999'))
if state.get('extra_process'): rows.append((700,state.get('extra_parent',1),state['extra_process']))
if 'rows' in state:
rows = [(1,0,'launchd'), (pid,parent,'/bin/sh -s --')] + state['rows']
if args[0] == '-p':
print(state.get('uids', {}).get(args[1], 501))
if state.get('reuse_on_uid') == args[1]:
state.setdefault('starts', {})[args[1]] = 'Wed Oct 7 13:00:00 2026'
save()
else:
for pid, ppid, command in rows:
start = state.get('starts', {}).get(str(pid), 'Wed Oct 7 12:00:00 2026')
print(f'{pid} {ppid} {start} {command}')
elif cmd == 'kill':
assert args[0] == '-TERM', args
event('signal '+args[1])
if state.get('kill_fail'): sys.exit(1)
elif cmd == 'networksetup':
if args[0] == '-getcurrentlocation': print(state['location'])
elif args[0] == '-listallnetworkservices':
print('An asterisk (*) denotes that a network service is disabled.')
print('\n'.join(state['services']))
elif args[0] == '-getdnsservers':
if args[1] not in state['services']: sys.exit(1)
dns=state['services'][args[1]]
print('\n'.join(dns) if dns else "There aren't any DNS Servers set on "+args[1]+'.')
elif args[0] == '-setdnsservers':
event('set '+json.dumps(args[1:]))
if args[1] in state.get('fail_write',[]): sys.exit(1)
state['services'][args[1]]=[] if args[2:] == ['empty'] else args[2:]
save()
else: raise AssertionError(args)
elif cmd == 'stock':
reason=os.environ['reason']
event('stock '+reason)
if reason in ('connect','reconnect') and os.environ.get('INTERNAL_IP4_DNS'):
assert (base/'root/journal').exists(), 'DNS changed before durable journal'
if state.get('pause_stock'):
(base/'entered').touch()
deadline=time.monotonic()+10
while not (base/'release').exists():
assert time.monotonic()<deadline, 'release timeout'
time.sleep(.01)
for service in state['vpn_services']:
state['services'][service]=os.environ['INTERNAL_IP4_DNS'].split()
save()
elif reason=='disconnect' and os.environ.get('INTERNAL_IP4_DNS'):
for service in state['vpn_services']: state['services'][service]=[]
save()
if state.get('stock_fail'): sys.exit(1)
elif cmd in ('dscacheutil','killall','sync'): pass
else: raise AssertionError(cmd)
'''
class Sandbox:
def __init__(self, folder):
self.path = folder
self.data = dict(boot='boot-one', owner=400, location='Automatic',
services={'Wi-Fi': [], 'USB LAN': ['1.1.1.1']},
vpn_services=['Wi-Fi'], vpn_active=False)
self.save()
fake = folder/'fake.py'
fake.write_text(FAKE)
fake.chmod(0o755)
source = SOURCE.read_text()
for name, real in {'id':'/usr/bin/id', 'stat':'/usr/bin/stat', 'sysctl':'/usr/sbin/sysctl',
'ps':'/bin/ps', 'networksetup':'/usr/sbin/networksetup',
'dscacheutil':'/usr/bin/dscacheutil', 'killall':'/usr/bin/killall',
'sync':'/bin/sync', 'kill':'/bin/kill'}.items():
tool = folder/name
tool.write_text('#!'+sys.executable+'\n'+FAKE)
tool.chmod(0o755)
source = source.replace(real, str(tool))
stock = folder/'vpnc-script'
stock.write_text('#!/bin/sh\nexec '+shlex.quote(sys.executable)+' '+shlex.quote(str(fake))+' stock\n')
stock.chmod(0o755)
source = source.replace('/opt/homebrew/etc/vpnc/vpnc-script', str(stock))
source = source.replace('/usr/local/etc/vpnc/vpnc-script', str(stock))
source = source.replace('/var/db/lemana-vpn-dns', str(folder/'root'))
source = source.replace('mode=$1\n', 'mode=$1\nTEST_SHELL_PID=$$; export TEST_SHELL_PID\n')
self.helper = folder/'helper'
self.helper.write_text(source)
self.helper.chmod(0o755)
def save(self): (self.path/'system.json').write_text(json.dumps(self.data))
def read(self):
self.data = json.loads((self.path/'system.json').read_text())
return self.data
def update(self, **kwargs):
self.read().update(kwargs)
self.save()
def environment(self, reason=None, dns='10.200.0.53 10.200.0.54'):
env = dict(os.environ)
env.update(VPNPID=str(self.data['owner']), INTERNAL_IP4_DNS=dns)
if reason: env.update(reason=reason, TEST_HOOK='1')
return env
def run(self, reason=None, ok=True, dns='10.200.0.53 10.200.0.54'):
result = subprocess.run([str(self.helper), '--vpnc' if reason else '--recover'],
env=self.environment(reason, dns), text=True, capture_output=True, timeout=20)
assert (result.returncode==0) == ok, (reason,result.returncode,result.stdout,result.stderr)
self.read()
return result.stdout.strip()
def connect(self): self.run('connect')
@property
def journal(self): return self.path/'root/journal'
def events(self): return (self.path/'events').read_text() if (self.path/'events').exists() else ''
def wait_for(path):
deadline = time.monotonic()+10
while not path.exists():
assert time.monotonic()<deadline, 'fixture never reached expected state'
time.sleep(.01)
def check(folder, name, test):
path = folder/name
path.mkdir()
test(Sandbox(path))
print('PASS', name)
def reboot(s):
s.data['services']['Wi-Fi']=['9.9.9.9','2620:fe::fe']
s.data['vpn_services']=['Wi-Fi','USB LAN']
s.save(); s.connect()
s.update(boot='boot-two', reused_pid=True)
assert s.run()=='restored'
assert s.data['services']=={'Wi-Fi':['9.9.9.9','2620:fe::fe'], 'USB LAN':['1.1.1.1']}
assert not s.journal.exists()
assert s.run()=='no_snapshot'
def disconnect(s):
s.data['services']['Wi-Fi']=['9.9.9.9']; s.save(); s.connect()
s.run('disconnect')
assert s.data['services']['Wi-Fi']==['9.9.9.9']
assert not s.journal.exists()
def changed(s):
s.connect()
s.data['services']['Wi-Fi']=['8.8.8.8']; s.save()
assert s.run()=='unchanged'
assert s.data['services']['Wi-Fi']==['8.8.8.8']
assert 'set ' not in s.events()
s.connect()
s.data['services']['Wi-Fi']=['9.9.9.9']; s.save()
s.run('disconnect')
assert s.data['services']['Wi-Fi']==['9.9.9.9']
def active(s):
assert s.run()=='no_snapshot'
for command in ('python /fake/openconnect-lite', '/opt/homebrew/bin/openconnect',
'/bin/bash /fake/vpn-lemanapro.sh --manual'):
s.update(extra_process=command)
result=s.run()
assert result=='active', (command,result)
s.update(extra_process=None); s.connect()
original=s.journal.read_text()
for command in ('python /fake/openconnect-lite', '/opt/homebrew/bin/openconnect'):
s.update(extra_process=command, extra_parent=200)
s.run('connect',ok=False)
s.run('disconnect',ok=False)
assert s.journal.read_text()==original and s.events().count('stock ')==1
s.update(extra_process=None)
s.update(vpn_active=True)
assert s.run()=='active'
assert s.journal.read_text()==original and 'set ' not in s.events()
def uncertain(s):
s.connect()
for flag in ('ps_fail','ps_empty'):
s.update(**{flag:True}); s.run(ok=False)
assert s.journal.exists() and 'set ' not in s.events()
s.update(**{flag:False})
def cleanup_process(s):
s.connect()
cli = (ROOT / 'bin/vpn-lemanapro.sh').read_text()
cleanup = cli[cli.index('_dns_cleanup() {'):cli.index('_repair_dns() {')]
command = cleanup.split("exec /bin/sh -c '", 1)[1].split("' dns-recovery", 1)[0]
s.update(extra_process='/bin/sh -c ' + command.replace('\n', ' ') +
' dns-recovery /usr/local/sbin/lemana-vpn-dns-cleanup 130 true')
assert s.run() == 'restored', 'The exit recovery command must not detect itself as an active VPN'
assert s.data['services']['Wi-Fi'] == [] and not s.journal.exists()
def location(s):
s.connect(); original=s.journal.read_text()
s.update(location='Office')
for reason in (None,'connect','disconnect'):
s.run(reason,ok=False)
assert s.journal.read_text()==original
assert s.events().count('stock ')==1 and 'set ' not in s.events()
def partial(s):
s.data['vpn_services']=['Wi-Fi','USB LAN']; s.save(); s.connect()
s.update(fail_write=['USB LAN'])
s.run(ok=False)
assert s.data['services']['Wi-Fi']==[] and s.journal.exists()
original=s.journal.read_text()
s.update(owner=401)
s.run('connect',ok=False)
assert s.journal.read_text()==original and s.events().count('stock connect')==1
s.update(fail_write=[])
assert s.run()=='restored' and s.data['services']['USB LAN']==['1.1.1.1']
def disconnect_retry(s):
s.data['services']['Wi-Fi']=['9.9.9.9']; s.save(); s.connect()
s.update(fail_write=['Wi-Fi'])
s.run('disconnect',ok=False)
assert s.journal.exists() and s.data['services']['Wi-Fi']==[]
s.update(fail_write=[],boot='boot-two')
assert s.run()=='restored' and s.data['services']['Wi-Fi']==['9.9.9.9']
def reconnect(s):
s.connect()
s.run('reconnect',dns='10.201.0.53')
assert s.run()=='restored' and s.data['services']['Wi-Fi']==[]
s.run('pre-init')
s.run('attempt-reconnect')
s.run('unrecognised',ok=False)
def new_service(s):
s.connect()
s.data['services']['Thunderbolt Ethernet']=['9.9.9.9']
s.data['vpn_services']=['Thunderbolt Ethernet']
s.save()
s.run('disconnect')
assert s.data['services']['Thunderbolt Ethernet']==['9.9.9.9']
assert s.data['services']['Wi-Fi']==[] and not s.journal.exists()
def no_dns(s):
s.run('connect',dns='')
assert not s.journal.exists() and s.data['services']['Wi-Fi']==[]
s.run('disconnect',dns='')
s.connect()
original=s.journal.read_text()
s.run('reconnect',dns='')
assert s.journal.read_text()==original
assert s.run()=='restored'
def literal_service_name(s):
name=r'Ethernet \ corp'
s.data['services'][name]=['9.9.9.9']
s.data['vpn_services']=[name]
s.save(); s.connect(); s.run('reconnect'); s.run('disconnect')
assert s.data['services'][name]==['9.9.9.9'] and not s.journal.exists()
def ownership(s):
s.connect(); original=s.journal.read_text()
s.update(owner=401)
s.run('disconnect',ok=False)
assert s.journal.read_text()==original and s.events().count('stock ')==1
s.update(owner=400,boot='boot-two')
s.run('disconnect',ok=False)
assert s.journal.read_text()==original
def protected_state(s):
s.data['services']['Wi-Fi']=['10.4.5.6']; s.save()
assert s.run()=='no_snapshot' and s.data['services']['Wi-Fi']==['10.4.5.6']
s.connect()
s.journal.chmod(0o666)
s.run(ok=False)
assert 'set ' not in s.events()
s.journal.chmod(0o600)
saved=s.path/'saved'
s.journal.rename(saved)
s.journal.symlink_to(saved)
s.run(ok=False)
assert saved.exists() and 'set ' not in s.events()
def invalid(s):
s.run('connect',ok=False,dns='10.0.0.53;touch /tmp/should-not-exist')
assert not s.journal.exists() and not s.events()
s.connect()
s.journal.write_text(s.journal.read_text().replace('10.200.0.53','$(touch /tmp/should-not-exist)'))
s.run(ok=False)
assert 'set ' not in s.events()
def concurrent(s):
s.update(pause_stock=True)
hook = subprocess.Popen([str(s.helper),'--vpnc'],env=s.environment('connect'),
text=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
wait_for(s.path/'entered')
repair = subprocess.Popen([str(s.helper),'--recover'],env=s.environment(),
text=True,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
# Stock has the real native lock until released; recovery cannot inspect partial state.
(s.path/'release').touch()
out,err=hook.communicate(timeout=15)
assert hook.returncode==0,(out,err)
out,err=repair.communicate(timeout=15)
assert repair.returncode==0 and out.strip()=='restored',(out,err)
assert s.read()['services']['Wi-Fi']==[] and not s.journal.exists()
if __name__ == '__main__':
with tempfile.TemporaryDirectory(prefix='lemana-dns-') as temporary:
folder=Path(temporary)
for name,test in [('reboot-custom-dns-and-reused-pid',reboot),('normal-custom-dns-disconnect',disconnect),
('preserve-user-changes',changed),('active-vpn-sso-and-cli',active),
('indeterminate-process-state',uncertain),('network-location',location),
('exit-recovery-process-is-not-vpn',cleanup_process),
('partial-failure-and-new-connect',partial),('disconnect-crash-window',disconnect_retry),
('reconnect-and-hook-reasons',reconnect),('no-dns-tunnel',no_dns),('new-network-service',new_service),('literal-service-name',literal_service_name),('owner-identity',ownership),('protected-journal-and-legacy-dns',protected_state),('invalid-input-and-journal',invalid),
('native-lock-concurrency',concurrent)]:
check(folder,name,test)
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Render native icons into memory and inspect pixels; no app, windows or screenshots."""
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[1]
with tempfile.TemporaryDirectory(prefix="lemana-icons-") as temporary:
folder = Path(temporary)
source = folder / "IconChecks.swift"
source.write_text(r'''
import AppKit
func pixels(size: Int = 96, _ draw: (CGContext) -> Void) -> [UInt8] {
let context = CGContext(data: nil, width: size, height: size, bitsPerComponent: 8, bytesPerRow: size * 4,
space: CGColorSpace(name: CGColorSpace.sRGB)!,
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue | CGBitmapInfo.byteOrder32Big.rawValue)!
context.translateBy(x: 0, y: CGFloat(size))
context.scaleBy(x: CGFloat(size) / 24, y: -CGFloat(size) / 24)
draw(context)
return Array(UnsafeBufferPointer(start: context.data!.assumingMemoryBound(to: UInt8.self), count: size * size * 4))
}
func session(_ style: VPNIndicatorStyle, _ progress: Double?, dark: Bool, urgency: SessionTiming.Urgency = .normal) -> [UInt8] {
pixels { VPNIcons.drawSession(style: style, progress: progress, urgency: urgency, in: $0, dark: dark) }
}
func pixel(_ bytes: [UInt8], _ x: Int, _ y: Int) -> [UInt8] {
let index = (y * 4 * 96 + x * 4) * 4
return Array(bytes[index..<index + 4])
}
@main struct IconChecks {
static func main() {
precondition(VPNIndicatorStyle(rawValue: "ring") == .ring)
precondition(VPNIndicatorStyle(rawValue: "clock") == .clock)
precondition(VPNIndicatorStyle(rawValue: "time") == .time)
for dark in [false, true] {
let image = VPNIcons.sessionImage(style: .shield, progress: 0.5, urgency: .normal, dark: dark)
precondition(image.size == NSSize(width: 16, height: 16))
let native = image.cgImage(forProposedRect: nil, context: nil, hints: nil)!
let actual = pixels { context in
context.translateBy(x: 0, y: 24)
context.scaleBy(x: 1, y: -1)
context.draw(native, in: CGRect(x: 0, y: 0, width: 24, height: 24))
}
precondition(pixel(actual, 12, 8)[3] < pixel(actual, 12, 18)[3], "native NSImage must fill from bottom")
var seen = Set<[UInt8]>()
for icon in VPNIcon.allCases {
let bytes = pixels { VPNIcons.draw(icon, in: $0, dark: dark) }
precondition(bytes.contains(where: { $0 != 0 }), "empty icon: \(icon)")
precondition(seen.insert(bytes).inserted, "duplicate icon: \(icon)")
for size in [16, 32] {
let compact = pixels(size: size) { VPNIcons.draw(icon, in: $0, dark: dark) }
let count = stride(from: 3, to: compact.count, by: 4).filter { compact[$0] > 30 }.count
precondition(count > 10, "invisible icon at \(size): \(icon)")
}
}
for style in [VPNIndicatorStyle.shield, .ring] {
let full = session(style, 0, dark: dark)
let quarter = session(style, 0.75, dark: dark)
let half = session(style, 0.5, dark: dark)
let spent = session(style, 1, dark: dark)
precondition(pixel(full, 12, 8)[3] > 200, "session must start brightly filled")
precondition((75...80).contains(pixel(spent, 12, 18)[3]), "spent interior must stay visible")
precondition(pixel(half, 7, 10) == pixel(spent, 7, 10), "spent part must dim from top")
precondition(pixel(half, 12, 18) == pixel(full, 12, 18), "remaining part must stay bright")
precondition(pixel(quarter, 12, 18) == pixel(half, 12, 18))
precondition(pixel(quarter, 7, 14) == pixel(spent, 7, 14))
precondition(pixel(full, 12, 18) == pixel(half, 12, 18))
precondition(pixel(full, 1, 1)[3] == 0, "fill escaped its shape")
precondition(session(style, -1, dark: dark) == full)
precondition(session(style, 2, dark: dark) == spent)
precondition(session(style, nil, dark: dark) == pixels { VPNIcons.draw(.noTime, in: $0, dark: dark) })
precondition(session(style, .nan, dark: dark) == session(style, nil, dark: dark))
let warning = pixel(session(style, 0.5, dark: dark, urgency: .warning), 12, 18)
let critical = pixel(session(style, 0.5, dark: dark, urgency: .critical), 12, 18)
precondition(warning != pixel(half, 12, 18) && warning != critical, "urgency colors must differ")
// Expiry stays legible over the dimmed interior in either theme.
let expired = session(style, 1, dark: dark, urgency: .critical)
precondition(pixel(expired, 12, 8)[3] > pixel(expired, 12, 18)[3])
}
precondition(session(.clock, 0.25, dark: dark) != session(.clock, 0.75, dark: dark))
precondition(session(.time, 0.5, dark: dark) != session(.time, 1, dark: dark))
}
print("PASS native icons: both themes, distinct states/actions, 16/32 px visibility, bright remaining fill, dim spent area, clipping, urgency, expiry and unknown time")
}
}
''')
binary = folder / "icon-check"
subprocess.run(["swiftc", "-parse-as-library", "-module-cache-path", str(ROOT / "app/.build/log-test-module-cache"),
str(ROOT / "app/Sources/LemanaVPN/SessionTiming.swift"),
str(ROOT / "app/Sources/LemanaVPN/VPNIcons.swift"), str(source), "-o", str(binary)], check=True)
subprocess.run([str(binary)], check=True)
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""Filesystem and closed-input regressions; no VPN, Keychain, or GUI."""
import json
import os
from pathlib import Path
import subprocess
import tempfile
ROOT = Path(__file__).resolve().parents[1]
RUNTIME = Path(os.environ.get("LEMANA_TEST_RUNTIME", ROOT / "bin/vpn-lemanapro.sh"))
SOURCE = RUNTIME.read_text()
def function(name):
start = SOURCE.index(name + "() {")
return SOURCE[start:SOURCE.index("\n}\n", start) + 3]
def shell(code, env, stdin="", timeout=5):
return subprocess.run(["/bin/bash", "-c", code], input=stdin, text=True,
capture_output=True, env=env, timeout=timeout)
with tempfile.TemporaryDirectory(prefix="lemana-logging-") as tmp:
home = Path(tmp)
env = {k: v for k, v in os.environ.items()
if not k.startswith(("LEMANA_VPN_", "OPENCONNECT_LITE_"))}
env.update(HOME=tmp, LEMANA_VPN_CONFIG_DIR=str(home / "config"),
LEMANA_VPN_STATUS_DIR=str(home / "state"))
# EOF must differ from an intentional empty Enter, even in a conditional caller.
prompts = "\n".join(function(n) for n in (
"_emit", "_prompt_read", "_ensure_username", "_configure_login", "_configure_keychain", "_bw_unlock"))
setup = '''
set -eu
JSON_MODE=true
KC_USERNAME=''
CACHE_BW_SESSION=0
USE_TOUCHID=0
LOCAL_OTP=0
USE_BITWARDEN=1
_can_prompt() { return 0; }
_tty_available() { return 1; }
_keychain_has() { return 0; }
_persist_username() { echo unexpected-write; }
_store_keychain() { echo unexpected-write; }
_bw_cache_session() { :; }
bw() { case "$1" in status) echo '{"status":"locked"}';; unlock) echo fake-session;; esac; }
'''
for call in ("_ensure_username", "_configure_login", "_configure_keychain", "_bw_unlock"):
result = shell(prompts + setup + f"\nif {call}; then exit 99; else exit 7; fi", env)
assert result.returncode == 7, (call, result.stdout, result.stderr)
assert "unexpected-write" not in result.stdout
assert len(result.stderr) < 500, (call, result.stderr)
result = shell(prompts + setup + '\nif _configure_keychain; then exit 99; else exit 7; fi', env, "fake-password\n")
assert result.returncode == 7 and "unexpected-write" not in result.stdout, result.stderr
result = shell(prompts + setup + '\nvalue_out=x; _prompt_read value_out ""; test -z "$value_out"', env, "\n")
assert result.returncode == 0, result.stderr
helper = home / "fingerprint"
helper.write_text('#!/bin/sh\n[ "$1" != set ] || echo unexpected-write >> "$HOME/secret-write"\n')
helper.chmod(0o755)
result = shell(prompts + setup + f'\nUSE_TOUCHID=1; KC_FP="{helper}"; BW_KC_SERVICE=fake; BW_KC_ACCOUNT_MASTER=fake\n'
'if _bw_unlock; then exit 99; else exit 7; fi', env, "fake-password\n")
assert result.returncode == 7, result.stderr
assert not (home / "secret-write").exists(), "EOF consent must not save a password"
print("closed input: username/password/2FA/Bitwarden/save consent exit without persistence; empty Enter unchanged")
log = home / "nested" / "client.log"
log.parent.mkdir()
log.write_bytes(b"legacy" * 10000)
log.chmod(0o644)
log_functions = "\n".join(function(n) for n in (
"_append_connection_log", "_log_connection_line", "_prepare_connection_log", "_parse_expiry", "_valid_ipv4", "_publish_connection", "_filter_output"))
log_setup = f'\nCONNECT_LOG_FILE="{log}"; LOG_MAX_BYTES=256; _connection_log_failed=false; OC_BIN=fake\n'
result = shell(log_functions + log_setup + '''
_prepare_connection_log
for ((i=0; i<80; i++)); do _log_connection_line "record-$i: Привет"; done
_log_connection_line "$(printf '%02000d' 1)"
_log_connection_line newest
''', env)
assert result.returncode == 0, result.stderr
for p in (log, Path(str(log) + ".1")):
assert p.stat().st_size <= 256, (p, p.stat().st_size)
assert p.stat().st_mode & 0o777 == 0o600, p
assert log.read_bytes().endswith(b"newest\n")
assert len(list(log.parent.iterdir())) == 2
# A new process with a lower setting also bounds the previous archive.
result = shell(log_functions + log_setup + 'LOG_MAX_BYTES=64; _log_connection_line reduced', env)
assert result.returncode == 0, result.stderr
assert all(p.stat().st_size <= 64 for p in log.parent.iterdir())
# Logging failure cannot stop the output consumer, and warns only once.
result = shell(log_functions + log_setup + f'CONNECT_LOG_FILE="{log}/invalid"\n' + '''
DEBUG=true; JSON_MODE=false
printf 'first\nsecond\n' | _filter_output
''', env)
assert result.returncode == 0, result.stderr
assert result.stdout == "first\nsecond\n", result.stdout
assert result.stderr.count("Cannot write VPN log") == 1, result.stderr
result = shell(function("_emit") + log_functions + log_setup + f'CONNECT_LOG_FILE="{log}/invalid"\n' + '''
set -euo pipefail
DEBUG=false; JSON_MODE=true
scutil() { :; }
_write_status() { :; }
_stop_connect_progress() { :; }
printf 'Configured as 10.2.3.4\nSession authentication will expire at invalid-date\n' | _filter_output
''', env)
assert result.returncode == 0, result.stderr
connected = json.loads(result.stdout)
assert connected.pop("started_at")
assert connected == {"event": "connected", "ip": "10.2.3.4", "expires": "", "remaining_sec": 0, "attempt_id": ""}
print("CLI log: legacy + repeated + oversized writes <= 256 bytes per file; newest kept; I/O failure preserves output")
config = home / "config"
config.mkdir()
config.joinpath("env").write_text('LEMANA_VPN_LOG_MAX_BYTES="384"\nLEMANA_VPN_BW_ITEM=\'Corporate "VPN" & / office\'\n')
status = subprocess.run(["bash", str(RUNTIME), "--status", "--json"], env=env,
text=True, capture_output=True, check=True)
modules = json.loads(status.stdout)["modules"]
assert modules["logging"]["max_bytes"] == 384
assert modules["bitwarden"]["item"] == 'Corporate "VPN" & / office'
home.joinpath("status.json").write_text(status.stdout)
for limit in ("", "0", "-1", "abc", "1000000000"):
result = subprocess.run(["bash", str(RUNTIME), "--status"], env=dict(env, LEMANA_VPN_LOG_MAX_BYTES=limit),
text=True, capture_output=True)
assert result.returncode == 2, (limit, result.stdout, result.stderr)
result = subprocess.run(["bash", str(RUNTIME), "--statuz", "--status"], env=env,
text=True, capture_output=True)
assert result.returncode == 2 and "Unknown argument" in result.stderr
assert not (home / "state").exists()
print("CLI settings: saved limit and quoted item round-trip; malformed limits and unknown flags rejected")
# Compile the actual Foundation logger and actual status types, without starting the app.
manager_source = (ROOT / "app/Sources/LemanaVPN/VPNManager.swift").read_text()
types = manager_source.split("enum VPNState:")[0]
types += manager_source[manager_source.index("enum VPNLaunchMode:"):manager_source.index("struct VPNLineBuffer")]
home.joinpath("Status.swift").write_text(types)
home.joinpath("main.swift").write_text(r'''
import Foundation
let dir = URL(fileURLWithPath: CommandLine.arguments[1])
let response = try JSONDecoder().decode(VPNStatusResponse.self, from: Data(contentsOf: dir.appendingPathComponent("status.json")))
let url = dir.appendingPathComponent("swift/app.log")
var log = BoundedLog(url: url)
precondition(log.maxBytes == 5242880)
log.maxBytes = response.modules!.logging!.max_bytes
precondition(log.maxBytes == 384)
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try Data(repeating: 65, count: 100_000).write(to: url)
for i in 0..<80 { try log.append("record-\(i): Привет\n") }
try log.append(String(repeating: "Ж", count: 2000) + "\n")
try log.append("newest\n")
for path in [url, url.appendingPathExtension("1")] {
let attrs = try FileManager.default.attributesOfItem(atPath: path.path)
precondition((attrs[.size] as! NSNumber).intValue <= 384)
precondition((attrs[.posixPermissions] as! NSNumber).intValue == 0o600)
}
let latest = try String(contentsOf: url, encoding: .utf8)
precondition(latest.hasSuffix("newest\n"))
log.maxBytes = 64
try log.append("reduced\n")
for path in [url, url.appendingPathExtension("1")] {
let bytes = try Data(contentsOf: path)
precondition(bytes.count <= 64)
}
do {
try BoundedLog(url: url.appendingPathComponent("invalid")).append("failed\n")
fatalError("Expected I/O failure")
} catch {}
print("Swift log: decoded CLI limit; legacy/repeated/oversized writes bounded; newest retained; I/O errors throwable")
''')
binary = home / "log-check"
subprocess.run(["swiftc", "-module-cache-path", str(ROOT / "app/.build/log-test-module-cache"),
str(ROOT / "app/Sources/LemanaVPN/BoundedLog.swift"),
str(home / "Status.swift"), str(home / "main.swift"), "-o", str(binary)], check=True)
subprocess.run([str(binary), tmp], check=True)
print("logging checks ok")
+512
View File
@@ -0,0 +1,512 @@
#!/usr/bin/env python3
"""Session transcript and native presentation logic; no GUI or live VPN."""
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
ROOT = Path(__file__).resolve().parents[1]
SOURCE = (ROOT / "bin/vpn-lemanapro.sh").read_text()
def function(name):
start = SOURCE.index(name + "() {")
return SOURCE[start:SOURCE.index("\n}\n", start) + 3]
with tempfile.TemporaryDirectory(prefix="lemana-menu-") as temporary:
folder = Path(temporary)
env = {key: value for key, value in os.environ.items() if not key.startswith(("LEMANA_VPN_", "OPENCONNECT_LITE_"))}
env.update(HOME=temporary, TZ="UTC", TEST_FOLDER=temporary)
code = "\n".join(function(name) for name in ("_emit", "_write_status", "_json_get", "_parse_expiry", "_valid_ipv4", "_publish_connection", "_filter_output", "_check_status"))
code += r'''
set -euo pipefail
JSON_MODE=true; DEBUG=false
STATUS_DIR="$TEST_FOLDER/state"; STATUS_FILE="$STATUS_DIR/status.json"
_log_connection_line() { :; }
_stop_connect_progress() { :; }
scutil() { :; }
kill() { return 0; }
ping() { return 0; }
_module_status_json() { printf '{}'; }
date() {
if [[ "$*" == '-u +%Y-%m-%dT%H:%M:%SZ' ]]; then
local count=0
[[ ! -f "$TEST_FOLDER/clock" ]] || read -r count < "$TEST_FOLDER/clock"
count=$((count+1)); printf '%s\n' "$count" > "$TEST_FOLDER/clock"
printf '2030-01-01T00:00:%02dZ\n' "$count"
else
/bin/date "$@"
fi
}
printf 'Configured as 10.2.3.4\nSession authentication will expire at Tue Jan 01 02:00:00 2030\nSession authentication will expire at Tue Jan 01 02:00:00 2030\n' | _filter_output > "$TEST_FOLDER/events.jsonl"
_check_status > "$TEST_FOLDER/status.json"
printf 'Configured as 10.2.3.4\nSession authentication will expire at Tue Jan 01 02:00:00 2030\n' | _filter_output > "$TEST_FOLDER/reconnected.jsonl"
'''
subprocess.run(["bash", "-c", code], env=env, check=True, capture_output=True, text=True)
events = [json.loads(line) for line in folder.joinpath("events.jsonl").read_text().splitlines()]
assert len(events) == 3
assert events[0]["event"] == "connected" and events[0]["expires"] == ""
assert {event["started_at"] for event in events} == {"2030-01-01T00:00:01Z"}
assert {event["expires"] for event in events[1:]} == {"2030-01-01T02:00:00Z"}
status = json.loads(folder.joinpath("status.json").read_text())
assert status["state"] == "connected" and status["started_at"] == events[0]["started_at"]
assert json.loads(folder.joinpath("state/status.json").read_text())["started_at"] != events[0]["started_at"]
print("PASS CLI: stable event/status origin across repeated expiry messages; fresh origin after reconnect")
modules = {
"core": {"openconnect": True, "openconnect_lite": True, "config": True, "openconnect_lite_config": True},
"bitwarden": {"enabled": False, "installed": False},
"touchid": {"enabled": False, "installed": False},
"keychain": {"password": True, "totp_seed": True},
"dns_cleanup": {"installed": True, "recovery_supported": True, "stop_supported": True},
"patches": {"active": True, "backup": True},
"auth": {"mode": "auto", "source": "keychain", "auto_source": "keychain", "label": "Automatic", "detail": "Local key", "ready": True},
}
fake_cli = r'''#!/bin/sh
folder="$(dirname "$0")"
printf '%s\n' "$*" >> "$folder/calls"
case "$*" in
'--status --json') cat "$folder/status"; exit "$(cat "$folder/status-code")" ;;
'--repair-dns --json')
recovery="$(cat "$folder/recovery")"
touch "$folder/recovery-started"
while [ -f "$folder/hold" ]; do sleep 0.01; done
printf '%s\n' "$recovery"
exit "$(cat "$folder/recovery-code")"
;;
'--set-auth=manual') exit 0 ;;
--restart-session=*)
[ "$1" = "--restart-session=$(cat "$folder/session")" ] || exit 89
[ "$2" = "--restart-attempt=22222222-2222-3333-4444-555555555555" ] || exit 88
touch "$folder/restart-requested"
printf 'stopping\n'
exit 0 ;;
--stop-session=*)
[ "$1" = "--stop-session=$(cat "$folder/session")" ] || exit 89
printf 'requested\n' >> "$folder/stop-requests"
while [ -f "$folder/hold-stop-response" ]; do sleep 0.01; done
[ ! -f "$folder/stop-fail" ] || exit 91
touch "$folder/stop-requested"
printf '{"event":"session_stop","result":"stopping"}\n'
exit 0 ;;
'--json --session-id='*)
printf '%s' "${2#--session-id=}" > "$folder/session"
[ ! -f "$folder/client.py" ] || exec "$TEST_PYTHON" "$folder/client.py"
exit 9 ;;
*) printf 'unexpected command\n' > "$folder/unexpected"; exit 79 ;;
esac
'''
env["TEST_PYTHON"] = sys.executable
os.environ["TEST_PYTHON"] = sys.executable
for name in ("restored", "unchanged", "no_snapshot", "active", "error", "legacy", "unsupported", "bad_status", "status_failure", "bad_recovery", "recovery_failure", "waiting", "ip_timer", "auth_terminal", "runtime_error", "cli_retries", "health_restart", "disconnect", "stop_failure", "stop_timeout", "stop_command_timeout", "late_registration", "quit_dns_error", "early_quit", "recovery_race", "late_recovery_race"):
case = folder / name
case.mkdir()
case.joinpath("cli").write_text(fake_cli)
case.joinpath("status").write_text(json.dumps({"modules": modules}))
case.joinpath("status-code").write_text("7" if name == "status_failure" else "0")
case.joinpath("recovery-code").write_text("8" if name in ("error", "recovery_failure") else "0")
case.joinpath("recovery").write_text(json.dumps({"event": "dns_recovery", "result": name if name in ("restored", "unchanged", "no_snapshot", "active", "error") else "restored", "message": "Нет прав на восстановление DNS. Выполните sh install.sh." if name == "error" else ""}))
if name in ("legacy", "unsupported"):
status_modules = json.loads(json.dumps(modules))
if name == "legacy":
del status_modules["dns_cleanup"]["recovery_supported"]
else:
status_modules["dns_cleanup"]["recovery_supported"] = False
case.joinpath("status").write_text(json.dumps({"modules": status_modules}))
if name == "bad_status":
case.joinpath("status").write_text("not JSON")
if name == "bad_recovery":
case.joinpath("recovery").write_text("not JSON")
if name == "waiting":
case.joinpath("hold").touch()
client = '''import json, signal, sys, time
from pathlib import Path
folder = Path(__file__).parent
def interrupt(signum, frame):
(folder / 'unexpected-signal').touch()
sys.exit(99)
signal.signal(signal.SIGINT, interrupt)
signal.signal(signal.SIGTERM, interrupt)
(folder / 'client-ready').touch()
deadline = time.monotonic() + 20
while (folder / 'hold-ready').exists():
if time.monotonic() > deadline: sys.exit(98)
time.sleep(.01)
print(json.dumps({'event': 'session_ready'}), flush=True)
while not (folder / 'stop-requested').exists() and not (folder / 'release-client').exists():
if time.monotonic() > deadline: sys.exit(98)
time.sleep(.01)
print(json.dumps({'event': 'dns_cleanup'}), flush=True)
print(json.dumps({'event': 'connecting'}), flush=True)
while not (folder / 'release-client').exists():
if time.monotonic() > deadline: sys.exit(98)
time.sleep(.01)
sys.exit(0)
'''
for name in ('disconnect', 'stop_failure', 'stop_timeout', 'stop_command_timeout', 'late_registration', 'quit_dns_error', 'early_quit', 'recovery_race', 'late_recovery_race'):
folder.joinpath(name, 'client.py').write_text(client)
folder.joinpath('stop_failure/stop-fail').touch()
folder.joinpath('early_quit/hold-ready').touch()
folder.joinpath('stop_command_timeout/hold-stop-response').touch()
folder.joinpath('late_registration/hold-ready').touch()
folder.joinpath('late_registration/hold-stop-response').touch()
runtime_client = client[:client.index("while not (folder / 'stop-requested').exists()")]+r'''
def emit(**event): print(json.dumps(event), flush=True)
def wait_file(name):
while not (folder/name).exists():
if time.monotonic() > deadline: sys.exit(98)
time.sleep(.01)
name = folder.name
if name in ('auth_terminal', 'runtime_error'):
emit(event='auth_rejected' if name == 'auth_terminal' else 'error', message='Input rejected' if name == 'auth_terminal' else 'DNS not ready')
time.sleep(.1)
emit(event='connected', ip='10.2.3.4')
emit(event='dns_cleanup')
sys.exit(77 if name == 'auth_terminal' else 1)
if name == 'cli_retries':
emit(event='reconnecting', attempt=2)
wait_file('advance')
emit(event='connected', ip='10.2.3.4', attempt_id='22222222-2222-3333-4444-555555555555', started_at='2030-01-01T00:00:01Z', expires='')
if name == 'ip_timer':
wait_file('advance')
emit(event='session_info', ip='10.2.3.4', started_at='2030-01-01T00:00:01Z', expires='2030-01-01T02:00:00Z')
if name == 'health_restart':
wait_file('restart-requested')
emit(event='reconnecting', attempt=1)
wait_file('release-client')
emit(event='dns_cleanup')
'''
for name in ('ip_timer', 'auth_terminal', 'runtime_error', 'cli_retries', 'health_restart'):
folder.joinpath(name, 'client.py').write_text(runtime_client)
# Replace only OS probes/interval in a disposable copy, never inspect the real network.
manager_source = (ROOT/'app/Sources/LemanaVPN/VPNManager.swift').read_text()
for executable, body in [('scutil', 'if [ -f "'+temporary+'/health-active" ]; then printf "resolver #1\\nSupplemental\\nnameserver[0] : 10.2.3.4\\n"; fi'), ('ping', 'exit 1')]:
path = folder/executable
path.write_text('#!/bin/sh\n'+body+'\n'); path.chmod(0o755)
real = '/usr/sbin/scutil' if executable == 'scutil' else '/sbin/ping'
assert manager_source.count('"'+real+'"') == 1
manager_source = manager_source.replace('"'+real+'"', '"'+str(path)+'"')
assert 'healthCheckInterval: TimeInterval = 10' in manager_source
manager_source = manager_source.replace('healthCheckInterval: TimeInterval = 10', 'healthCheckInterval: TimeInterval = 0.05')
folder.joinpath('VPNManager.swift').write_text(manager_source)
folder.joinpath("MenuChecks.swift").write_text(r'''
import Foundation
@main struct MenuChecks {
@MainActor static func waitUntil(_ condition: () -> Bool, _ context: String) async throws {
let deadline = Date().addingTimeInterval(5)
while !condition() {
precondition(Date() < deadline, "Timed out: \(context)")
try await Task.sleep(nanoseconds: 10_000_000)
}
}
@MainActor static func main() async throws {
let directory = URL(fileURLWithPath: CommandLine.arguments[1])
let formatter = ISO8601DateFormatter()
let start = formatter.date(from: "2030-01-01T00:00:01Z")!
let end = formatter.date(from: "2030-01-01T02:00:00Z")!
let middle = start.addingTimeInterval(end.timeIntervalSince(start) / 2)
let raw = try String(contentsOf: directory.appendingPathComponent("events.jsonl"), encoding: .utf8)
let events = try raw.split(separator: "\n").map { try JSONDecoder().decode(VPNEvent.self, from: Data($0.utf8)) }
var timing = SessionTiming()
for event in events {
timing.observe(startedAt: event.started_at, expires: event.expires, now: middle)
}
precondition(timing.progress(at: start) == 0)
precondition(abs(timing.progress(at: middle)! - 0.5) < 0.000001)
precondition(timing.progress(at: end.addingTimeInterval(1)) == 1)
precondition(timing.progress(at: start.addingTimeInterval(-1)) == 0)
precondition(timing.urgency(at: end.addingTimeInterval(-3601)) == .normal)
precondition(timing.urgency(at: end.addingTimeInterval(-3600)) == .warning)
precondition(timing.urgency(at: end.addingTimeInterval(-901)) == .warning)
precondition(timing.urgency(at: end.addingTimeInterval(-900)) == .critical)
precondition(timing.urgency(at: end) == .critical)
precondition(timing.compactRemaining(at: end.addingTimeInterval(-59)) == "0:01")
precondition(timing.compactRemaining(at: end) == "0:00")
precondition(timing.remainingLabel(at: end) == "Время сессии истекло")
timing = SessionTiming()
precondition(timing.remaining(at: start) == nil && timing.progress(at: start) == nil)
timing.observe(startedAt: nil, expires: "2030-01-01T02:00:00Z", now: start)
timing.observe(startedAt: nil, expires: "2030-01-01T02:00:00Z", now: middle)
precondition(timing.startedAt == start)
timing.observe(startedAt: "invalid", expires: "invalid", now: middle)
precondition(timing.progress(at: middle) == nil && timing.urgency(at: middle) == .unknown)
timing = SessionTiming()
timing.observe(startedAt: "2030-01-01T03:00:00Z", expires: "2030-01-01T02:00:00Z", now: start)
precondition(timing.progress(at: middle) == nil)
precondition(timing.remaining(at: middle) == nil)
precondition(timing.urgency(at: middle) == .unknown)
precondition(timing.remainingLabel(at: middle) == "Срок сессии неизвестен")
print("PASS Swift: actual duration, clamped fill, urgency boundaries, expiry and legacy/invalid/reset cases")
let legacyMenu = try JSONDecoder().decode(ModuleStatus.AppMenu.self, from: Data("{\"install_label\":\"Manual fallback\",\"configured_mode\":\"saved-login\",\"available_modes\":[\"manual-full\"]}".utf8))
precondition(legacyMenu.available_modes == ["manual-full"])
print("PASS legacy status metadata remains decodable during upgrade")
let json = "{\"event\":\"session_info\",\"message\":\"Проверка\",\"expires\":\"2030-01-01T02:00:00Z\",\"started_at\":\"2030-01-01T00:00:01Z\"}"
let bytes = Data((json + "\n" + json + "\n").utf8)
for split in 1..<bytes.count {
var buffer = VPNLineBuffer()
let lines = buffer.append(Data(bytes.prefix(split))) + buffer.append(Data(bytes.dropFirst(split)))
precondition(lines == [json, json])
}
// The previous callback tried decoding each partial chunk independently.
let partial = Data(json.utf8).prefix(50)
precondition((try? JSONDecoder().decode(VPNEvent.self, from: partial)) == nil)
var buffer = VPNLineBuffer()
var collected: [String] = []
for byte in bytes { collected += buffer.append(Data([byte])) }
precondition(collected == [json, json])
print("PASS event transport: every byte split and multibyte UTF-8 preserve complete JSON lines")
for name in ["restored", "unchanged", "no_snapshot", "active", "error", "legacy", "unsupported", "bad_status", "status_failure", "bad_recovery", "recovery_failure"] {
let fixture = directory.appendingPathComponent(name)
let manager = VPNManager(scriptPath: fixture.appendingPathComponent("cli").path, logURL: fixture.appendingPathComponent("app.log"))
precondition(manager.isCheckingConnection && !manager.canConnect && !manager.canSetAuth)
try await waitUntil({ !manager.isCheckingConnection }, name)
let commands = try String(contentsOf: fixture.appendingPathComponent("calls"), encoding: .utf8).split(separator: "\n").map(String.init)
let unsupported = ["legacy", "unsupported", "bad_status", "status_failure"].contains(name)
precondition(commands == (unsupported ? ["--status --json"] : ["--status --json", "--repair-dns --json"]), "Unsafe arguments: \(name), \(commands)")
if ["restored", "unchanged", "no_snapshot"].contains(name) {
precondition(manager.canConnect && manager.canSetAuth && manager.canConfigureCredentials)
precondition(!manager.connectionCheckFailed && !manager.isExternallyActive)
} else {
precondition(!manager.canConnect && !manager.canSetAuth && !manager.canConfigureCredentials)
precondition(manager.connectionCheckMessage?.isEmpty == false)
precondition(manager.connectionCheckFailed == (name != "active"))
}
if name == "error" {
precondition(manager.connectionCheckMessage!.contains("Нет прав"))
}
if name == "active" {
precondition(manager.isExternallyActive)
try "{\"event\":\"dns_recovery\",\"result\":\"unchanged\"}".write(to: fixture.appendingPathComponent("recovery"), atomically: true, encoding: .utf8)
manager.refreshConnectionStatus()
try await waitUntil({ !manager.isCheckingConnection }, "external session ended")
precondition(manager.canConnect && !manager.isExternallyActive)
}
}
print("PASS startup DNS: capability-gated CLI, old CLI untouched, failures block, active session avoids duplicate and refresh clears it")
let fixture = directory.appendingPathComponent("waiting")
let manager = VPNManager(scriptPath: fixture.appendingPathComponent("cli").path, logURL: fixture.appendingPathComponent("app.log"))
try await waitUntil({ FileManager.default.fileExists(atPath: fixture.appendingPathComponent("recovery-started").path) }, "recovery starts")
precondition(manager.moduleStatus?.auth?.ready == true && !manager.canConnect && !manager.canSetAuth)
manager.connect()
manager.setAuth("manual")
manager.configureLogin()
manager.importYandexKey()
manager.refreshStatus()
manager.refreshConnectionStatus()
let duringRecovery = try String(contentsOf: fixture.appendingPathComponent("calls"), encoding: .utf8)
precondition(duringRecovery == "--status --json\n--repair-dns --json\n")
try FileManager.default.removeItem(at: fixture.appendingPathComponent("hold"))
try await waitUntil({ !manager.isCheckingConnection }, "recovery released")
precondition(manager.canConnect && manager.canSetAuth)
manager.setAuth("manual")
try await waitUntil({ !manager.isSavingAuth }, "auth saved")
let afterAuth = try String(contentsOf: fixture.appendingPathComponent("calls"), encoding: .utf8)
precondition(afterAuth == "--status --json\n--repair-dns --json\n--set-auth=manual\n--status --json\n")
precondition(manager.canConnect && manager.lastError == nil)
print("PASS recovery serialization: connect/configuration blocked while waiting; passive auth refresh does not run recovery")
manager.connect()
try await waitUntil({
let commands = (try? String(contentsOf: fixture.appendingPathComponent("calls"), encoding: .utf8)) ?? ""
return commands.split(separator: "\n").filter { $0 == "--repair-dns --json" }.count == 2
&& !manager.isCheckingConnection && !manager.isRunning && manager.state.label.hasPrefix("error(")
}, "recovery after connection process ended")
precondition(manager.canConnect)
print("PASS connection termination: app recovers DNS again after its CLI exits")
for name in ["ip_timer", "auth_terminal", "runtime_error", "cli_retries", "health_restart"] {
let fixture = directory.appendingPathComponent(name)
let manager = VPNManager(scriptPath: fixture.appendingPathComponent("cli").path, logURL: fixture.appendingPathComponent("app.log"))
try await waitUntil({ manager.canConnect }, name + " ready")
if name == "health_restart" { try Data().write(to: directory.appendingPathComponent("health-active")) }
manager.connect(mode: .auto)
if name == "cli_retries" {
try await waitUntil({ manager.state == .reconnecting(attempt: 2) }, "CLI owns retries")
precondition(manager.isRunning && !manager.canConnect)
try Data().write(to: fixture.appendingPathComponent("advance"))
}
if name == "ip_timer" || name == "cli_retries" {
try await waitUntil({ manager.state.label.hasPrefix("connected(") }, "IP establishes connected status")
precondition(manager.sessionTiming.expiresAt == nil && manager.remainingTime == "")
if name == "ip_timer" {
let origin = manager.sessionTiming.startedAt
try Data().write(to: fixture.appendingPathComponent("advance"))
try await waitUntil({ manager.sessionTiming.expiresAt != nil }, "late expiry")
precondition(manager.sessionTiming.startedAt == origin)
}
}
if name == "health_restart" {
try await waitUntil({ manager.state == .reconnecting(attempt: 1) }, "owned-engine restart")
try FileManager.default.removeItem(at: directory.appendingPathComponent("health-active"))
}
try Data().write(to: fixture.appendingPathComponent("release-client"))
try await waitUntil({ !manager.isRunning && manager.canConnect }, name + " finished and DNS recovered")
if name == "auth_terminal" || name == "runtime_error" {
precondition(manager.state.label.hasPrefix("error(") && manager.lastError != nil, "Terminal failure survives cleanup")
} else {
precondition(manager.state == .disconnected && manager.sessionTiming.startedAt == nil)
}
let calls = try String(contentsOf: fixture.appendingPathComponent("calls"), encoding: .utf8)
precondition(calls.split(separator: "\n").filter { $0.hasPrefix("--json --session-id=") }.count == 1, "App must never retry authentication independently")
precondition(calls.split(separator: "\n").filter { $0.hasPrefix("--restart-session=") }.count == (name == "health_restart" ? 1 : 0))
precondition(!FileManager.default.fileExists(atPath: fixture.appendingPathComponent("unexpected-signal").path), "Health monitor must not kill the CLI")
print("PASS runtime presentation: \(name)")
}
let disconnectFixture = directory.appendingPathComponent("disconnect")
let disconnectManager = VPNManager(scriptPath: disconnectFixture.appendingPathComponent("cli").path, logURL: disconnectFixture.appendingPathComponent("app.log"))
try await waitUntil({ disconnectManager.canConnect }, "disconnect setup")
disconnectManager.connect()
try await waitUntil({ FileManager.default.fileExists(atPath: disconnectFixture.appendingPathComponent("client-ready").path) }, "client ready")
disconnectManager.disconnect()
precondition(disconnectManager.state.label == "disconnecting")
precondition(!disconnectManager.canConnect && !disconnectManager.canConfigureCredentials)
try await waitUntil({ FileManager.default.fileExists(atPath: disconnectFixture.appendingPathComponent("stop-requests").path) }, "owned stop requested")
try await waitUntil({
let log = (try? String(contentsOf: disconnectFixture.appendingPathComponent("app.log"), encoding: .utf8)) ?? ""
return log.contains("[event] connecting")
}, "late connection event received")
precondition(disconnectManager.state.label == "disconnecting", "Late events must not leave disconnecting")
disconnectManager.disconnect()
var quitAllowed: Bool? = nil
disconnectManager.quit { quitAllowed = $0 }
precondition(quitAllowed == nil, "Quit must wait for the client")
try Data().write(to: disconnectFixture.appendingPathComponent("hold"))
try Data().write(to: disconnectFixture.appendingPathComponent("release-client"))
try await waitUntil({ disconnectManager.isCheckingConnection }, "post-disconnect recovery")
precondition(disconnectManager.state.label == "disconnecting")
precondition(!disconnectManager.canConnect && !disconnectManager.isExternallyActive)
precondition(quitAllowed == nil, "Quit must wait for DNS recovery")
try FileManager.default.removeItem(at: disconnectFixture.appendingPathComponent("hold"))
try await waitUntil({ disconnectManager.canConnect }, "DNS recovered after disconnect")
precondition(disconnectManager.state == .disconnected && !disconnectManager.connectionCheckFailed)
precondition(quitAllowed == true, "Quit completes only after client exit and DNS success")
precondition(!FileManager.default.fileExists(atPath: disconnectFixture.appendingPathComponent("unexpected-signal").path))
let stops = try String(contentsOf: disconnectFixture.appendingPathComponent("stop-requests"), encoding: .utf8)
precondition(stops == "requested\n", "Repeated disconnect must not repeat stop")
print("PASS disconnect and quit: one owned stop, late events ignored, client exit and DNS confirmed before quit")
for name in ["stop_failure", "stop_timeout", "stop_command_timeout", "late_registration", "quit_dns_error", "early_quit"] {
let fixture = directory.appendingPathComponent(name)
let manager = VPNManager(scriptPath: fixture.appendingPathComponent("cli").path,
logURL: fixture.appendingPathComponent("app.log"), disconnectTimeout: name.contains("timeout") || name == "late_registration" ? 0.5 : 5)
try await waitUntil({ manager.canConnect }, name + " setup")
manager.connect()
try await waitUntil({ FileManager.default.fileExists(atPath: fixture.appendingPathComponent("client-ready").path) }, name + " client")
var allowed: Bool? = nil
manager.quit { allowed = $0 }
if name == "early_quit" {
precondition(allowed == nil)
precondition(!FileManager.default.fileExists(atPath: fixture.appendingPathComponent("stop-requests").path))
try FileManager.default.removeItem(at: fixture.appendingPathComponent("hold-ready"))
try await waitUntil({ FileManager.default.fileExists(atPath: fixture.appendingPathComponent("stop-requests").path) }, "queued stop after registration")
}
if name == "quit_dns_error" {
try Data("1".utf8).write(to: fixture.appendingPathComponent("recovery-code"))
try Data().write(to: fixture.appendingPathComponent("release-client"))
} else if name == "early_quit" {
try Data().write(to: fixture.appendingPathComponent("release-client"))
}
try await waitUntil({ allowed != nil }, name + " quit reply")
precondition(allowed == (name == "early_quit"), "Failed stop or DNS must cancel application quit")
if name == "late_registration" {
try FileManager.default.removeItem(at: fixture.appendingPathComponent("hold-ready"))
try await waitUntil({ FileManager.default.fileExists(atPath: fixture.appendingPathComponent("stop-requests").path) }, "late registration starts a bounded stop")
try await waitUntil({ manager.canRetryDisconnect }, "late registration stop also times out")
try FileManager.default.removeItem(at: fixture.appendingPathComponent("hold-stop-response"))
}
precondition(!FileManager.default.fileExists(atPath: fixture.appendingPathComponent("unexpected-signal").path))
if name != "early_quit" {
precondition(manager.canRetryDisconnect && !manager.canConnect)
if name != "quit_dns_error" { precondition(manager.isRunning, "Failed stop must not kill the CLI") }
try Data("0".utf8).write(to: fixture.appendingPathComponent("recovery-code"))
if name == "stop_failure" { try FileManager.default.removeItem(at: fixture.appendingPathComponent("stop-fail")) }
if name == "stop_command_timeout" { try FileManager.default.removeItem(at: fixture.appendingPathComponent("hold-stop-response")) }
}
try Data().write(to: fixture.appendingPathComponent("release-client"))
var retryQuit: Bool? = nil
if name != "early_quit" { manager.quit { retryQuit = $0 } }
try await waitUntil({ !manager.isRunning && manager.canConnect }, name + " retry/cleanup")
if name != "early_quit" { precondition(retryQuit == true, "A second Quit must remain usable after failure") }
print("PASS quit: \(name)")
}
for raceName in ["recovery_race", "late_recovery_race"] {
let race = directory.appendingPathComponent(raceName)
let raceManager = VPNManager(scriptPath: race.appendingPathComponent("cli").path, logURL: race.appendingPathComponent("app.log"))
try await waitUntil({ raceManager.canConnect }, "race setup")
raceManager.connect()
try await waitUntil({
((try? String(contentsOf: race.appendingPathComponent("app.log"), encoding: .utf8)) ?? "").contains("[event] session_ready")
}, "race session registered")
try Data().write(to: race.appendingPathComponent("hold"))
try Data(#"{"event":"dns_recovery","result":"active"}"#.utf8).write(to: race.appendingPathComponent("recovery"))
try FileManager.default.removeItem(at: race.appendingPathComponent("recovery-started"))
try Data().write(to: race.appendingPathComponent("release-client"))
try await waitUntil({ !raceManager.isRunning && raceManager.isCheckingConnection }, "automatic recovery pending")
try await waitUntil({ FileManager.default.fileExists(atPath: race.appendingPathComponent("recovery-started").path) }, "old recovery captured active")
if raceName == "recovery_race" { try Data().write(to: race.appendingPathComponent("hold-stop-response")) }
var raceQuit: Bool? = nil
raceManager.quit { raceQuit = $0 }
try await waitUntil({ FileManager.default.fileExists(atPath: race.appendingPathComponent("stop-requests").path) }, "stop pending")
if raceName == "late_recovery_race" {
try await waitUntil({
((try? String(contentsOf: race.appendingPathComponent("app.log"), encoding: .utf8)) ?? "").contains("session stop acknowledged")
}, "stop acknowledged before old recovery response")
}
try Data(#"{"event":"dns_recovery","result":"restored"}"#.utf8).write(to: race.appendingPathComponent("recovery"))
try FileManager.default.removeItem(at: race.appendingPathComponent("hold"))
if raceName == "recovery_race" {
try await waitUntil({ !raceManager.isCheckingConnection }, "old recovery completed")
precondition(raceQuit == nil && !raceManager.canConnect, "Old recovery cannot finish an unacknowledged stop")
try FileManager.default.removeItem(at: race.appendingPathComponent("hold-stop-response"))
}
try await waitUntil({ raceQuit != nil }, "fresh recovery completes quit")
precondition(raceQuit == true)
let raceCalls = try String(contentsOf: race.appendingPathComponent("calls"), encoding: .utf8)
precondition(raceCalls.split(separator: "\n").filter { $0 == "--repair-dns --json" }.count == 3)
print("PASS quit race: pre-stop recovery ignored; fresh recovery after stop acknowledgment")
}
let cli = directory.appendingPathComponent("fake ' cli $literal").path
for (name, argument, message) in [("login", "--configure-login", "Логин и пароль обновлены."), ("import", "--import-yandex-key", "Код импортирован.")] {
let script = VPNManager.terminalScript(scriptPath: cli, argument: argument, successMessage: message)
try script.write(to: directory.appendingPathComponent(name + ".command"), atomically: true, encoding: .utf8)
}
}
}
''')
binary = folder / "menu-check"
subprocess.run(["swiftc", "-parse-as-library", "-module-cache-path", str(ROOT / "app/.build/log-test-module-cache"),
str(ROOT / "app/Sources/LemanaVPN/SessionTiming.swift"),
str(folder / "VPNManager.swift"),
str(ROOT / "app/Sources/LemanaVPN/BoundedLog.swift"), str(folder / "MenuChecks.swift"), "-o", str(binary)], check=True)
subprocess.run([str(binary), temporary], check=True)
cli = folder / "fake ' cli $literal"
cli.write_text('#!/bin/sh\nprintf "%s" "$1" > "$TEST_FOLDER/captured-argument"\nexit "${TEST_EXIT_CODE:-0}"\n')
cli.chmod(0o700)
for name, expected in (("login", "--configure-login"), ("import", "--import-yandex-key")):
for code in (0, 7):
result = subprocess.run(["zsh", str(folder / (name + ".command"))], input="\n", text=True,
capture_output=True, env=dict(env, TEST_EXIT_CODE=str(code)))
assert result.returncode == code, result.stderr
assert folder.joinpath("captured-argument").read_text() == expected
assert "read-only" not in result.stderr
assert ("Код импортирован." in result.stdout) == (name == "import" and code == 0)
print("PASS generated Terminal scripts: exact quoted action, per-action success and preserved failure exit code")
print("menu checks ok")
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Privileged stop ownership, cancellation races and DNS preservation; no real signals."""
from pathlib import Path
import subprocess
import sys
import tempfile
sys.dont_write_bytecode = True
from dns_recovery import Sandbox, check
TOKEN = '11111111-2222-3333-4444-555555555555'
ATTEMPT = '22222222-2222-3333-4444-555555555555'
def call(s, operation, *args, ok=True, **extra):
env = dict(s.environment(), SUDO_UID='501')
env.update(extra)
result = subprocess.run([str(s.helper), operation, *args], env=env,
text=True, capture_output=True, timeout=20)
assert (result.returncode == 0) == ok, (result.stdout, result.stderr)
s.read()
return result.stdout.strip()
def begin(s):
s.update(rows=[[200, 1, '/bin/bash /fake/vpn-lemanapro.sh --json'],
[300, 200, 'python /fake/openconnect-lite']], uids={'400': 0})
call(s, '--begin', TOKEN, '200', TEST_BEGIN='1')
def connect(s, dns='10.200.0.53', attempt=ATTEMPT, engine=400):
s.data.update(owner=engine, uids={str(engine): 0})
s.data['rows'].append([engine, 300, '/opt/homebrew/bin/openconnect --cookie-on-stdin'])
s.save()
call(s, '--vpnc', TOKEN, attempt, reason='connect', TEST_HOOK='1', INTERNAL_IP4_DNS=dns)
def owned_stop(s):
begin(s); connect(s)
before = s.journal.read_bytes()
s.data['rows'].append([900, 1, '/opt/homebrew/bin/openconnect --other-vpn'])
s.save()
assert call(s, '--stop', TOKEN) == 'stopping'
assert s.events().splitlines() == ['stock connect', 'signal 400']
assert s.journal.read_bytes() == before
assert s.data['services']['Wi-Fi'] == ['10.200.0.53']
# Stop released the native lock; the disconnect hook can restore custom DNS.
s.data['rows'].pop(); s.save()
call(s, '--vpnc', TOKEN, reason='disconnect', TEST_HOOK='1', INTERNAL_IP4_DNS='10.200.0.53')
assert s.data['services']['Wi-Fi'] == [] and not s.journal.exists()
def cancelled_sso(s):
begin(s)
assert call(s, '--stop', TOKEN) == 'stopping'
assert set(s.events().splitlines()) == {'signal 200', 'signal 300'}
s.data['rows'].append([400, 300, '/opt/homebrew/bin/openconnect'])
s.save()
# A child that starts after the stop snapshot must never configure the network.
call(s, '--vpnc', TOKEN, ok=False, reason='pre-init', TEST_HOOK='1')
assert 'stock' not in s.events() and not s.journal.exists()
def rejected(s):
begin(s); connect(s)
for args, env in [((TOKEN.replace('1', '9'),), {}), ((TOKEN,), {'SUDO_UID': '502'})]:
call(s, '--stop', *args, ok=False, **env)
assert 'signal' not in s.events()
call(s, '--begin', TOKEN.replace('1', '9'), '200', ok=False, TEST_BEGIN='1')
s.update(boot='boot-two')
call(s, '--stop', TOKEN, ok=False)
assert 'signal' not in s.events()
def reused(s):
begin(s); connect(s)
s.update(starts={'200': 'Wed Oct 7 13:00:00 2026', '400': 'Wed Oct 7 13:00:00 2026'})
assert call(s, '--stop', TOKEN) == 'stopping'
assert 'signal' not in s.events(), 'Reused PIDs must never receive a signal'
def reuse_during_stop(s):
begin(s); connect(s)
s.update(reuse_on_uid='400')
call(s, '--stop', TOKEN)
assert 'signal' not in s.events(), 'Recheck engine identity immediately before signaling'
def no_dns(s):
begin(s); connect(s, dns='')
assert not s.journal.exists()
call(s, '--stop', TOKEN)
assert s.events().splitlines() == ['stock connect', 'signal 400']
def failures(s):
begin(s); connect(s)
s.update(ps_fail=True)
call(s, '--stop', TOKEN, ok=False)
assert 'signal' not in s.events()
s.update(ps_fail=False, kill_fail=True)
call(s, '--stop', TOKEN, ok=False)
assert s.journal.exists()
assert s.events().splitlines() == ['stock connect', 'signal 400']
def hook_ownership(s):
begin(s)
s.data['rows'].append([400, 1, '/opt/homebrew/bin/openconnect'])
s.save()
call(s, '--vpnc', TOKEN, ok=False, reason='connect', TEST_HOOK='1')
assert not s.journal.exists() and not s.events()
s.data['rows'].pop(); s.save(); connect(s)
s.update(uids={'400': 502})
call(s, '--stop', TOKEN, ok=False)
assert 'signal' not in s.events()
s.update(uids={'400': 0})
s.data['rows'][-1][2] = '/usr/bin/sleep 100'
s.save()
call(s, '--stop', TOKEN, ok=False)
assert 'signal' not in s.events()
def protected(s):
begin(s)
session = s.path/'root/session'
saved = s.path/'saved'
session.rename(saved); session.symlink_to(saved)
call(s, '--stop', TOKEN, ok=False)
assert not s.events() and saved.exists()
def retry_recovery(s):
begin(s); connect(s)
before = s.journal.read_bytes()
assert call(s, '--recover-session', TOKEN, TEST_BEGIN='1') == 'active'
assert s.journal.read_bytes() == before
s.data['rows'] = s.data['rows'][:2]; s.save()
assert call(s, '--recover-session', TOKEN, TEST_BEGIN='1') == 'active', 'SSO still blocks retry'
s.data['rows'] = s.data['rows'][:1]; s.save()
for command in ['/opt/homebrew/bin/openconnect --other-vpn', '/bin/bash /fake/vpn-lemanapro.sh --json']:
s.data['rows'].append([900, 1, command]); s.save()
assert call(s, '--recover-session', TOKEN, TEST_BEGIN='1') == 'active'
assert s.journal.read_bytes() == before
s.data['rows'].pop(); s.save()
call(s, '--recover-session', TOKEN, ok=False) # unrelated caller
call(s, '--recover-session', TOKEN, ok=False, TEST_BEGIN='1', SUDO_UID='502')
call(s, '--recover-session', TOKEN.replace('1', '9'), ok=False, TEST_BEGIN='1')
s.update(starts={'200': 'Wed Oct 7 13:00:00 2026'})
call(s, '--recover-session', TOKEN, ok=False, TEST_BEGIN='1')
s.update(starts={})
assert call(s, '--recover-session', TOKEN, TEST_BEGIN='1') == 'restored'
assert not s.journal.exists() and s.data['services'] == {'Wi-Fi': [], 'USB LAN': ['1.1.1.1']}
def restart_engine(s):
begin(s); connect(s)
before = s.journal.read_bytes()
s.data['rows'].append([900, 1, '/opt/homebrew/bin/openconnect --other-vpn']); s.save()
assert call(s, '--restart', TOKEN, ATTEMPT) == 'stopping'
assert s.events().splitlines() == ['stock connect', 'signal 400']
assert (s.path/'root/session').read_text().strip().split('\t')[8] == '0'
assert s.journal.read_bytes() == before
s.data['rows'] = s.data['rows'][:1]; s.save()
assert call(s, '--recover-session', TOKEN, TEST_BEGIN='1') == 'restored'
s.data['rows'].append([300, 200, 'python /fake/openconnect-lite']); s.save()
connect(s)
assert s.data['services']['Wi-Fi'] == ['10.200.0.53'], 'New tunnel may reuse the registered CLI'
call(s, '--stop', TOKEN)
previous = s.events()
assert call(s, '--restart', TOKEN, ATTEMPT) == 'cancelled'
assert s.events() == previous, 'Restart cannot override Stop'
assert call(s, '--recover-session', TOKEN, TEST_BEGIN='1') == 'active'
s.data['rows'] = s.data['rows'][:1]; s.save()
assert call(s, '--recover-session', TOKEN, TEST_BEGIN='1') == 'cancelled'
assert not s.journal.exists() and s.data['services']['Wi-Fi'] == []
def restart_without_engine(s):
begin(s)
assert call(s, '--restart', TOKEN, ATTEMPT) == 'stale'
assert not s.events(), 'Restart must not kill the CLI or authentication children'
connect(s)
call(s, '--restart', TOKEN, ATTEMPT, ok=False, SUDO_UID='502')
call(s, '--restart', TOKEN.replace('1', '9'), ATTEMPT, ok=False)
s.update(starts={'400': 'Wed Oct 7 13:00:00 2026'})
call(s, '--restart', TOKEN, ATTEMPT)
assert 'signal' not in s.events(), 'Restart must not signal a reused engine PID'
def stale_restart(s):
begin(s); connect(s)
old_request = ('--restart', TOKEN, ATTEMPT)
s.data['rows'] = s.data['rows'][:1]; s.save()
assert call(s, '--recover-session', TOKEN, TEST_BEGIN='1') == 'restored'
s.data['rows'].append([300, 200, 'python /fake/openconnect-lite']); s.save()
next_attempt = ATTEMPT.replace('2', 'a')
connect(s, attempt=next_attempt, engine=401)
previous = s.events()
assert call(s, *old_request) == 'stale'
assert s.events() == previous, 'Delayed old request must leave replacement engine alive'
call(s, '--restart', TOKEN, next_attempt)
assert s.events().splitlines()[-1] == 'signal 401'
def legacy_session(s):
begin(s); connect(s)
record = s.path/'root/session'
fields = record.read_text().strip().split('\t')[:9]
fields[0] = '1'
record.write_text('\t'.join(fields)+'\n')
assert call(s, '--restart', TOKEN, ATTEMPT) == 'stale'
assert call(s, '--stop', TOKEN) == 'stopping'
assert s.events().splitlines()[-1] == 'signal 400', 'Existing session stays safely stoppable after upgrade'
with tempfile.TemporaryDirectory(prefix='lemana-stop-') as temporary:
for name, test in [('owned-engine-only-and-dns', owned_stop), ('cancel-before-engine', cancelled_sso),
('foreign-session-user-and-boot', rejected), ('pid-reuse', reused),
('pid-reuse-during-stop', reuse_during_stop),
('tunnel-without-dns', no_dns), ('inspection-and-signal-errors', failures),
('hook-ancestry-and-engine-identity', hook_ownership),
('protected-session-record', protected), ('retry-recovery-ownership', retry_recovery),
('restart-engine-and-cancel', restart_engine), ('restart-without-engine', restart_without_engine),
('delayed-restart-keeps-new-engine', stale_restart), ('legacy-session-upgrade', legacy_session)]:
check(Path(temporary), name, test)
+350 -97
View File
@@ -10,24 +10,45 @@ export LEMANA_VPN_BIN_DIR="$HOME/bin"
export LEMANA_VPN_CONFIG_DIR="$HOME/.config/lemana-vpn"
export OPENCONNECT_LITE_CONFIG_DIR="$HOME/.config/openconnect-lite"
mkdir -p "$HOME"
# Every connection below uses fake clients; cleanup and status must stay isolated too.
safe_bin="$TMP_DIR/safe-bin"
mkdir -p "$safe_bin"
printf '#!/bin/sh\ncase "$*" in *" --recover") printf "no_snapshot\\n" ;; *" --begin "*) printf "ready\\n" ;; *" --recover-session "*) printf "cancelled\\n" ;; esac\nexit 0\n' > "$safe_bin/sudo"
printf '#!/bin/sh\nexit 0\n' > "$safe_bin/networksetup"
printf '#!/bin/sh\nexit 1\n' > "$safe_bin/security"
chmod +x "$safe_bin/sudo" "$safe_bin/networksetup" "$safe_bin/security"
export PATH="$safe_bin:$PATH"
export LEMANA_VPN_CONNECT_WAIT_SECONDS=0.1
printf '#!/bin/sh\n# LEMANA_DNS_PROTOCOL=2\n# LEMANA_SESSION_PROTOCOL=1\n# LEMANA_RECONNECT_PROTOCOL=1\nprintf "no_snapshot\\n"\n' > "$TMP_DIR/fake-dns-cleanup"
chmod +x "$TMP_DIR/fake-dns-cleanup"
export LEMANA_VPN_DNS_CLEANUP="$TMP_DIR/fake-dns-cleanup"
output="$(cd "$ROOT" && sh install.sh --dry-run --non-interactive --minimal)"
printf '%s\n' "$output" | grep -q 'Detected state:'
printf '%s\n' "$output" | grep -q 'Interactive prompts: off'
printf '%s\n' "$output" | grep -q 'Modules: auth_profile=manual credential_source=none bitwarden=0 touchid=0 sudoers=1 shell=1 app=1 autostart=1'
printf '%s\n' "$output" | grep -q 'Modules: mode=manual-keychain bitwarden=0 touchid=0 sudoers=1 shell=1 app=1 autostart=1'
printf '%s\n' "$output" | grep -q 'Проверяю Homebrew-зависимости'
printf '%s\n' "$output" | grep -q 'Swift build может занять минуту'
printf '%s\n' "$output" | grep -q 'sudo install -d -m 755 -o root -g wheel /usr/local/sbin'
printf '%s\n' "$output" | grep -q 'sudo install -m 755 -o root -g wheel .* /usr/local/sbin/lemana-vpn-dns-cleanup'
printf '%s\n' "$output" | grep -q 'swift build -c release --package-path'
printf '%s\n' "$output" | grep -q 'launchctl load'
printf '%s\n' "$output" | grep -q 'restart LemanaVPN.app if running'
printf '%s\n' "$output" | grep -q '+ cp .*/bin/vpn-lemanapro.sh '
printf '%s\n' "$output" | grep -q '+ cp .*/bin/lemana-vpn-import-yandex.py '
if printf '%s\n' "$output" | grep -q 'curl -fsSL .*raw/branch/main'; then
echo "checkout install dry-run should use local files, not remote main" >&2
exit 1
fi
import_plan="$(sh "$ROOT/install.sh" --dry-run --non-interactive --import-yandex-key --without-app --no-shell --no-sudoers)"
printf '%s\n' "$import_plan" | grep -q -- 'vpn-lemanapro.sh --configure-login'
printf '%s\n' "$import_plan" | grep -q -- 'vpn-lemanapro.sh --import-yandex-key'
if sh "$ROOT/install.sh" --dry-run --non-interactive --mode bitwarden-auto --import-yandex-key >/dev/null 2>&1; then
echo 'installer must reject mixing Bitwarden mode with local OTP import' >&2
exit 1
fi
esc="$(printf '\033')"
if printf '%s\n' "$output" | grep -q "$esc"; then
echo "non-tty dry-run output contains ANSI color codes" >&2
@@ -39,24 +60,22 @@ if command -v expect >/dev/null 2>&1; then
ROOT="$ROOT" expect <<'EXPECT'
set timeout 30
spawn sh $env(ROOT)/install.sh --dry-run --interactive --without-app --no-shell --no-sudoers
expect "Как подключаться по умолчанию?"
expect "Какой режим использовать?"
expect "Выбор"
send "\r"
expect "Сохранить логин и пароль"
send "y\r"
expect "Импортировать код из Яндекс Ключа"
send "n\r"
expect eof
EXPECT
)"
printf '%s\n' "$interactive_output" | grep -q 'По умолчанию ничего не сохраняется'
printf '%s\n' "$interactive_output" | grep -q 'Как подключаться по умолчанию?'
printf '%s\n' "$interactive_output" | grep -q '1) Вводить всё вручную'
printf '%s\n' "$interactive_output" | grep -q '2) Запомнить логин и пароль'
printf '%s\n' "$interactive_output" | grep -q '3) Настроить автоматический вход'
printf '%s\n' "$interactive_output" | grep -q 'Сохранить логин и пароль в macOS Keychain для подстановки при входе?'
printf '%s\n' "$interactive_output" | grep -q 'Выбран режим: сохранить логин и пароль, одноразовый код вводить вручную'
printf '%s\n' "$interactive_output" | grep -q 'Есть два режима'
printf '%s\n' "$interactive_output" | grep -q 'Какой режим использовать?'
printf '%s\n' "$interactive_output" | grep -q '1) Ручной вход: запомнить логин и пароль'
printf '%s\n' "$interactive_output" | grep -q '2) Автоматический вход'
printf '%s\n' "$interactive_output" | grep -q 'Выбран режим: ручной вход: логин и пароль в Keychain, одноразовый код вручную'
printf '%s\n' "$interactive_output" | grep -q '+ .*/vpn-lemanapro.sh --configure-login'
if printf '%s\n' "$interactive_output" | grep -Eq 'credential_source|auth_profile|TOTP seed'; then
if printf '%s\n' "$interactive_output" | grep -Eq 'credential_source|auth_profile|saved-totp|TOTP seed'; then
echo "basic interactive wizard should not expose technical credential wording" >&2
exit 1
fi
@@ -65,15 +84,18 @@ fi
status_json="$(bash "$ROOT/bin/vpn-lemanapro.sh" --status --json)"
printf '%s\n' "$status_json" | grep -q '"modules":'
printf '%s\n' "$status_json" | grep -q '"app":'
printf '%s\n' "$status_json" | grep -q '"app_menu":{"install_label":"Режим: полностью вручную","configured_mode":"manual","available_modes":\["configured"\],"setup_hint":null}'
printf '%s\n' "$status_json" | grep -q '"mode":{"id":"manual-keychain"'
printf '%s\n' "$status_json" | grep -q '"app_menu":{"install_label":"Режим: ручной вход, логин и пароль не настроены","configured_mode":"manual-keychain","available_modes":\["configured","bitwarden-auto"\],"setup_hint":"Настройте в Terminal: vpn --configure-login"}'
keychain_status_json="$(LEMANA_VPN_CREDENTIAL_SOURCE=keychain bash "$ROOT/bin/vpn-lemanapro.sh" --status --json)"
printf '%s\n' "$keychain_status_json" | grep -q '"credentials":{"source":"keychain","auth_profile":"manual","login_ready":false,"keychain_ready":false}'
printf '%s\n' "$keychain_status_json" | grep -q '"mode":{"id":"manual-keychain"'
printf '%s\n' "$keychain_status_json" | grep -q '"credentials":{"source":"keychain","auth_profile":"saved-login","login_ready":false,"keychain_ready":false}'
printf '%s\n' "$keychain_status_json" | grep -q '"bitwarden":{"enabled":false'
printf '%s\n' "$keychain_status_json" | grep -q '"touchid":{"enabled":false'
bitwarden_status_json="$(LEMANA_VPN_CREDENTIAL_SOURCE=bitwarden bash "$ROOT/bin/vpn-lemanapro.sh" --status --json)"
printf '%s\n' "$bitwarden_status_json" | grep -q '"credentials":{"source":"bitwarden","auth_profile":"manual","login_ready":false,"keychain_ready":false}'
printf '%s\n' "$bitwarden_status_json" | grep -q '"mode":{"id":"bitwarden-auto"'
printf '%s\n' "$bitwarden_status_json" | grep -q '"credentials":{"source":"bitwarden","auth_profile":"auto","login_ready":false,"keychain_ready":false}'
printf '%s\n' "$bitwarden_status_json" | grep -q '"bitwarden":{"enabled":true'
status_password_bin="$TMP_DIR/status-password-bin"
@@ -120,39 +142,39 @@ exit 1
SH
chmod +x "$status_full_bin/security"
saved_login_missing_status="$(
manual_keychain_missing_status="$(
LEMANA_VPN_USERNAME="lemana-status-login-missing" \
LEMANA_VPN_AUTH_PROFILE=saved-login \
LEMANA_VPN_CREDENTIAL_SOURCE=keychain \
LEMANA_VPN_MODE=manual-keychain \
bash "$ROOT/bin/vpn-lemanapro.sh" --status --json
)"
printf '%s\n' "$saved_login_missing_status" | grep -q '"app_menu":{"install_label":"Режим: логин и пароль не настроены","configured_mode":"saved-login","available_modes":\["manual-full"\],"setup_hint":"Настройте в Terminal: vpn --configure-login"}'
printf '%s\n' "$manual_keychain_missing_status" | grep -q '"mode":{"id":"manual-keychain","label":"Вручную","ready":false'
printf '%s\n' "$manual_keychain_missing_status" | grep -q '"app_menu":{"install_label":"Режим: ручной вход, логин и пароль не настроены","configured_mode":"manual-keychain","available_modes":\["configured","bitwarden-auto"\],"setup_hint":"Настройте в Terminal: vpn --configure-login"}'
saved_login_ready_status="$(
manual_keychain_ready_status="$(
PATH="$status_password_bin:$PATH" \
LEMANA_VPN_USERNAME="lemana-status-login-ready" \
LEMANA_VPN_AUTH_PROFILE=saved-login \
LEMANA_VPN_CREDENTIAL_SOURCE=keychain \
LEMANA_VPN_MODE=manual-keychain \
bash "$ROOT/bin/vpn-lemanapro.sh" --status --json
)"
printf '%s\n' "$saved_login_ready_status" | grep -q '"app_menu":{"install_label":"Режим: логин и пароль сохранены","configured_mode":"saved-login","available_modes":\["configured","manual-full"\],"setup_hint":null}'
printf '%s\n' "$manual_keychain_ready_status" | grep -q '"mode":{"id":"manual-keychain","label":"Вручную","ready":true'
printf '%s\n' "$manual_keychain_ready_status" | grep -q '"app_menu":{"install_label":"Режим: ручной вход, логин и пароль сохранены","configured_mode":"manual-keychain","available_modes":\["configured","bitwarden-auto"\],"setup_hint":null}'
saved_totp_missing_status="$(
LEMANA_VPN_USERNAME="lemana-status-totp-missing" \
legacy_saved_totp_status="$(
LEMANA_VPN_USERNAME="lemana-status-legacy-saved-totp" \
LEMANA_VPN_AUTH_PROFILE=saved-totp \
LEMANA_VPN_CREDENTIAL_SOURCE=keychain \
bash "$ROOT/bin/vpn-lemanapro.sh" --status --json
)"
printf '%s\n' "$saved_totp_missing_status" | grep -q '"app_menu":{"install_label":"Режим: сохранённые данные не настроены","configured_mode":"saved-totp","available_modes":\["manual-full"\],"setup_hint":"Настройте в Terminal: vpn --configure-keychain"}'
printf '%s\n' "$legacy_saved_totp_status" | grep -q '"mode":{"id":"manual-keychain"'
saved_totp_ready_status="$(
bitwarden_auto_ready_status="$(
PATH="$status_full_bin:$PATH" \
LEMANA_VPN_USERNAME="lemana-status-totp-ready" \
LEMANA_VPN_AUTH_PROFILE=saved-totp \
LEMANA_VPN_CREDENTIAL_SOURCE=keychain \
LEMANA_VPN_USERNAME="lemana-status-bitwarden-ready" \
LEMANA_VPN_MODE=bitwarden-auto \
bash "$ROOT/bin/vpn-lemanapro.sh" --status --json
)"
printf '%s\n' "$saved_totp_ready_status" | grep -q '"app_menu":{"install_label":"Режим: сохранённые данные без автоотправки","configured_mode":"saved-totp","available_modes":\["configured","saved-login","manual-full","auto"\],"setup_hint":null}'
printf '%s\n' "$bitwarden_auto_ready_status" | grep -q '"mode":{"id":"bitwarden-auto"'
printf '%s\n' "$bitwarden_auto_ready_status" | grep -q '"app_menu":{"install_label":"Автоматически · Bitwarden","configured_mode":"bitwarden-auto","available_modes":\["configured","manual-keychain"\],"setup_hint":null}'
auto_ready_status="$(
PATH="$status_full_bin:$PATH" \
@@ -161,7 +183,7 @@ auto_ready_status="$(
LEMANA_VPN_CREDENTIAL_SOURCE=keychain \
bash "$ROOT/bin/vpn-lemanapro.sh" --status --json
)"
printf '%s\n' "$auto_ready_status" | grep -q '"app_menu":{"install_label":"Режим: автоматический вход","configured_mode":"auto","available_modes":\["configured","saved-totp","saved-login","manual-full"\],"setup_hint":null}'
printf '%s\n' "$auto_ready_status" | grep -q '"mode":{"id":"bitwarden-auto"'
personal_ldap='601''03293'
if grep -R "$personal_ldap" "$ROOT/bin" "$ROOT/install.sh" "$ROOT/uninstall.sh" "$ROOT/README.md" "$ROOT/templates" "$ROOT/app" "$ROOT/.agents" >/dev/null; then
echo "personal LDAP username must not be hardcoded in runtime, installer, app, docs, or agent guidance" >&2
@@ -180,11 +202,20 @@ grep -q 'LEMANA_VPN_AUTOFILL_TOTP' "$ROOT/bin/vpn-lemanapro.sh"
grep -q 'vpn-auto' "$ROOT/install.sh"
grep -q 'vpn-manual' "$ROOT/install.sh"
grep -q 'vpn-manual-full' "$ROOT/install.sh"
grep -q 'ForEach(vpnManager.availableLaunchModes)' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"
grep -q 'vpnManager.connect()' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"
if grep -q 'vpnManager.connect(mode:' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"; then
echo 'Connect must use saved configuration, not a launch override' >&2
exit 1
fi
grep -q 'vpnManager.setAuth(' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"
grep -q 'vpnManager.installModeLabel' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"
grep -q 'vpnManager.installSetupHint' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"
grep -q 'Button("Изменить логин и пароль")' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"
grep -q 'menuButton("Логин и пароль…"' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"
grep -q 'vpnManager.configureLogin()' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"
grep -q 'vpnManager.importYandexKey()' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"
grep -q 'menuBarExtraStyle(.window)' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"
grep -q 'AppStorage("menuBarIndicatorStyle")' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"
printf '%s\n' "$output" | grep -q 'app/Sources/LemanaVPN/SessionTiming.swift'
if grep -Fq 'connect(mode: .auto)' "$ROOT/app/Sources/LemanaVPN/LemanaVPNApp.swift"; then
echo "disconnected app menu should not hardcode the auto button" >&2
exit 1
@@ -195,9 +226,7 @@ if grep -Fq 'Button("Подставить только логин и парол
fi
grep -q 'app_menu' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift"
grep -q 'available_modes' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift"
grep -q 'menuTitle' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift"
grep -q 'Подключить полностью вручную' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift"
grep -q 'case "auth_profile":' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift"
grep -q 'case "mode", "auth_profile":' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift"
grep -q 'case "manual_sso", "auto_sso":' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift"
grep -q 'case "username_required", "username_saved":' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift"
grep -q 'func configureLogin()' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift"
@@ -213,6 +242,9 @@ import json
import sys
class Browser:
def __init__(self, profile):
page = QWebEnginePage(profile, self)
def run(self, display_mode, credentials, url_pattern, rules):
argv = sys.argv.copy()
if display_mode == "hidden":
@@ -305,7 +337,7 @@ if grep -q 'ScriptWorldId.MainWorld' "$fake_webengine"; then
exit 1
fi
if grep -q '__lemanaVpnClicked' "$fake_webengine"; then
echo "patched auto-fill should stay stateless like the original working setup" >&2
echo "obsolete click flag must not replace the bounded per-attempt guard" >&2
exit 1
fi
if grep -q 'valueSetter' "$fake_webengine"; then
@@ -341,6 +373,7 @@ printf '%s\n' "$uninstall_output" | grep -q 'Проверяю runtime-патчи
printf '%s\n' "$uninstall_output" | grep -q 'Удаляю sudoers и DNS cleanup wrapper'
printf '%s\n' "$uninstall_output" | grep -q 'killall LemanaVPN # if running'
printf '%s\n' "$uninstall_output" | grep -q 'Удаляю VPN-записи из macOS Keychain'
printf '%s\n' "$uninstall_output" | grep -q 'rm -f .*lemana-vpn-import-yandex.py'
if printf '%s\n' "$uninstall_output" | grep -q "$esc"; then
echo "non-tty uninstall dry-run output contains ANSI color codes" >&2
@@ -388,6 +421,24 @@ if printf '%s\n' "$missing_username_output" | grep -q '"event":"keychain_require
echo "missing username should fail before checking Keychain credentials" >&2
exit 1
fi
grep -q '^_tty_available() {' "$ROOT/bin/vpn-lemanapro.sh"
if ! sed -n '/^_prompt_read()/,/^}/p' "$ROOT/bin/vpn-lemanapro.sh" | grep -q 'if _tty_available; then'; then
echo "runtime prompt reads must use the robust /dev/tty availability probe" >&2
exit 1
fi
preserve_username_home="$TMP_DIR/preserve-username-home"
mkdir -p "$preserve_username_home/.config/lemana-vpn" "$preserve_username_home/.config/openconnect-lite"
printf 'LEMANA_VPN_USERNAME="lemana-preserved-user"\nLEMANA_VPN_MODE="bitwarden-auto"\n' > "$preserve_username_home/.config/lemana-vpn/env"
printf 'username = "lemana-preserved-user"\n' > "$preserve_username_home/.config/openconnect-lite/config.toml"
preserve_username_output="$(
cd "$ROOT" &&
HOME="$preserve_username_home" \
LEMANA_VPN_CONFIG_DIR="$preserve_username_home/.config/lemana-vpn" \
OPENCONNECT_LITE_CONFIG_DIR="$preserve_username_home/.config/openconnect-lite" \
sh install.sh --dry-run --non-interactive --mode bitwarden-auto --without-app --no-shell --no-sudoers
)"
printf '%s\n' "$preserve_username_output" | grep -q 'Preserving existing LDAP username from previous config'
fake_path_bin="$TMP_DIR/fake-path-bin"
mkdir -p "$fake_path_bin"
@@ -416,6 +467,9 @@ cat > "$fake_oc_bin" <<'SH'
#!/bin/sh
{
printf 'args=%s\n' "$*"
for arg in "$@"; do
printf 'arg=%s\n' "$arg"
done
printf 'autofill_disable=%s\n' "${LEMANA_VPN_AUTOFILL_DISABLE:-}"
printf 'autofill_click=%s\n' "${LEMANA_VPN_AUTOFILL_CLICK:-}"
printf 'autofill_totp=%s\n' "${LEMANA_VPN_AUTOFILL_TOTP:-}"
@@ -450,11 +504,11 @@ exit 1
SH
chmod +x "$fake_missing_keychain_bin/security"
default_manual_home="$TMP_DIR/default-manual-home"
default_manual_capture="$TMP_DIR/default-manual-capture"
mkdir -p "$default_manual_home"
mkdir -p "$default_manual_home/.config/openconnect-lite"
cat > "$default_manual_home/.config/openconnect-lite/config.toml" <<'TOML'
manual_keychain_missing_home="$TMP_DIR/manual-keychain-missing-home"
manual_keychain_missing_capture="$TMP_DIR/manual-keychain-missing-capture"
mkdir -p "$manual_keychain_missing_home"
mkdir -p "$manual_keychain_missing_home/.config/openconnect-lite"
cat > "$manual_keychain_missing_home/.config/openconnect-lite/config.toml" <<'TOML'
on_disconnect = "true"
[default_profile]
@@ -465,33 +519,34 @@ name = "Default-K"
[credentials]
username = ""
TOML
default_manual_output="$(
HOME="$default_manual_home" \
set +e
manual_keychain_missing_output="$(
HOME="$manual_keychain_missing_home" \
PATH="$fake_missing_keychain_bin:$PATH" \
LEMANA_VPN_CONFIG_DIR="$default_manual_home/.config/lemana-vpn" \
OPENCONNECT_LITE_CONFIG_DIR="$default_manual_home/.config/openconnect-lite" \
LEMANA_VPN_CONFIG_DIR="$manual_keychain_missing_home/.config/lemana-vpn" \
OPENCONNECT_LITE_CONFIG_DIR="$manual_keychain_missing_home/.config/openconnect-lite" \
LEMANA_VPN_USERNAME="lemana-missing-login-$$" \
LEMANA_VPN_MODE=manual-keychain \
LEMANA_VPN_OC_BIN="$fake_oc_bin" \
LEMANA_VPN_OC_PYTHON=python3 \
LEMANA_VPN_WEBENGINE_PROCESS="$fake_webengine" \
LEMANA_VPN_AUTHENTICATOR="$fake_authenticator" \
LEMANA_VPN_PATCH_BACKUP_DIR="$TMP_DIR/default-manual-patch-backups" \
LEMANA_VPN_DNS_CLEANUP="$TMP_DIR/no-dns-cleanup" \
LEMANA_VPN_CONNECT_LOG="$TMP_DIR/default-manual.log" \
LEMANA_VPN_CAPTURE_LAUNCH="$default_manual_capture" \
bash "$ROOT/bin/vpn-lemanapro.sh" --json
LEMANA_VPN_PATCH_BACKUP_DIR="$TMP_DIR/manual-keychain-missing-patch-backups" \
LEMANA_VPN_DNS_CLEANUP="$TMP_DIR/fake-dns-cleanup" \
LEMANA_VPN_CONNECT_LOG="$TMP_DIR/manual-keychain-missing.log" \
LEMANA_VPN_CAPTURE_LAUNCH="$manual_keychain_missing_capture" \
bash "$ROOT/bin/vpn-lemanapro.sh" --json 2>&1
)"
manual_keychain_missing_code=$?
set -e
printf '%s\n' "$default_manual_output" | grep -q '"event":"manual_sso","auth_profile":"manual","autofill":false,"submit":false'
if printf '%s\n' "$default_manual_output" | grep -Eq '"event":"(username_required|keychain_required)"'; then
echo "default manual mode should open the browser without saved username or Keychain credentials" >&2
[ "$manual_keychain_missing_code" -ne 0 ]
printf '%s\n' "$manual_keychain_missing_output" | grep -q '"event":"keychain_required"'
printf '%s\n' "$manual_keychain_missing_output" | grep -q 'vpn --configure-login'
if [ -f "$manual_keychain_missing_capture" ]; then
echo "manual-keychain without saved password should fail before launching openconnect-lite" >&2
exit 1
fi
grep -q -- '--browser-display-mode shown' "$default_manual_capture"
grep -q '^autofill_disable=1$' "$default_manual_capture"
grep -q '^autofill_click=0$' "$default_manual_capture"
grep -q '^autofill_totp=0$' "$default_manual_capture"
grep -q '^credentials_block=0$' "$default_manual_capture"
grep -q '^empty_username=0$' "$default_manual_capture"
fake_login_keychain_bin="$TMP_DIR/fake-login-keychain-bin"
mkdir -p "$fake_login_keychain_bin"
@@ -516,57 +571,255 @@ exit 1
SH
chmod +x "$fake_login_keychain_bin/security"
saved_login_capture="$TMP_DIR/saved-login-capture"
saved_login_output="$(
manual_keychain_capture="$TMP_DIR/manual-keychain-capture"
manual_keychain_output="$(
HOME="$HOME" \
PATH="$fake_login_keychain_bin:$PATH" \
LEMANA_VPN_USERNAME="lemana-saved-login-$$" \
LEMANA_VPN_AUTH_PROFILE=saved-login \
LEMANA_VPN_CREDENTIAL_SOURCE=keychain \
LEMANA_VPN_USERNAME="lemana-manual-keychain-$$" \
LEMANA_VPN_MODE=manual-keychain \
LEMANA_VPN_OC_BIN="$fake_oc_bin" \
LEMANA_VPN_OC_PYTHON=python3 \
LEMANA_VPN_WEBENGINE_PROCESS="$fake_webengine" \
LEMANA_VPN_AUTHENTICATOR="$fake_authenticator" \
LEMANA_VPN_PATCH_BACKUP_DIR="$TMP_DIR/saved-login-patch-backups" \
LEMANA_VPN_DNS_CLEANUP="$TMP_DIR/no-dns-cleanup" \
LEMANA_VPN_CONNECT_LOG="$TMP_DIR/saved-login.log" \
LEMANA_VPN_CAPTURE_LAUNCH="$saved_login_capture" \
LEMANA_VPN_PATCH_BACKUP_DIR="$TMP_DIR/manual-keychain-patch-backups" \
LEMANA_VPN_DNS_CLEANUP="$TMP_DIR/fake-dns-cleanup" \
LEMANA_VPN_CONNECT_LOG="$TMP_DIR/manual-keychain.log" \
LEMANA_VPN_CAPTURE_LAUNCH="$manual_keychain_capture" \
bash "$ROOT/bin/vpn-lemanapro.sh" --json
)"
printf '%s\n' "$saved_login_output" | grep -q '"event":"manual_sso","auth_profile":"saved-login","autofill":true,"totp":false,"submit":false'
if printf '%s\n' "$saved_login_output" | grep -q '"event":"keychain_required"'; then
echo "saved-login should not require a TOTP seed" >&2
printf '%s\n' "$manual_keychain_output" | grep -q '"event":"manual_sso","mode":"manual-keychain","autofill":true,"totp":false,"submit":false'
if printf '%s\n' "$manual_keychain_output" | grep -q '"event":"keychain_required"'; then
echo "manual-keychain should not require a TOTP seed" >&2
exit 1
fi
grep -q -- '--browser-display-mode shown' "$saved_login_capture"
grep -q '^autofill_disable=0$' "$saved_login_capture"
grep -q '^autofill_click=0$' "$saved_login_capture"
grep -q '^autofill_totp=0$' "$saved_login_capture"
grep -q -- '--browser-display-mode shown' "$manual_keychain_capture"
grep -q '^autofill_disable=0$' "$manual_keychain_capture"
grep -q '^autofill_click=0$' "$manual_keychain_capture"
grep -q '^autofill_totp=0$' "$manual_keychain_capture"
saved_totp_capture="$TMP_DIR/saved-totp-capture"
saved_totp_output="$(
# Legacy local OTP migrates to automatic local login; explicit manual modes override it.
for local_case in local manual full missing; do
local_capture="$TMP_DIR/otp-$local_case.capture"
local_path="$fake_path_bin:$PATH"
local_arg="--json"
[ "$local_case" != manual ] || local_arg="--manual"
[ "$local_case" != full ] || local_arg="--manual-full"
[ "$local_case" != missing ] || local_path="$fake_login_keychain_bin:$PATH"
set +e
local_output="$(
PATH="$local_path" \
LEMANA_VPN_USERNAME="lemana-local-otp" \
LEMANA_VPN_MODE=manual-keychain \
LEMANA_VPN_LOCAL_OTP=1 \
LEMANA_VPN_OC_BIN="$fake_oc_bin" \
LEMANA_VPN_OC_PYTHON=python3 \
LEMANA_VPN_WEBENGINE_PROCESS="$fake_webengine" \
LEMANA_VPN_AUTHENTICATOR="$fake_authenticator" \
LEMANA_VPN_PATCH_BACKUP_DIR="$TMP_DIR/local-otp-patch-backups" \
LEMANA_VPN_DNS_CLEANUP="$TMP_DIR/fake-dns-cleanup" \
LEMANA_VPN_CONNECT_LOG="$TMP_DIR/otp-$local_case.log" \
LEMANA_VPN_CAPTURE_LAUNCH="$local_capture" \
bash "$ROOT/bin/vpn-lemanapro.sh" "$local_arg" --json
)"
local_code=$?
set -e
if [ "$local_case" = missing ]; then
[ "$local_code" -ne 0 ]
[ ! -f "$local_capture" ]
printf '%s\n' "$local_output" | grep -q 'vpn --import-yandex-key'
else
[ "$local_code" -eq 0 ]
if [ "$local_case" = local ]; then
grep -q -- '--browser-display-mode hidden' "$local_capture"
grep -q '^autofill_click=1$' "$local_capture"
grep -q '^autofill_totp=1$' "$local_capture"
printf '%s\n' "$local_output" | grep -q '"event":"auto_sso","mode":"keychain-auto","autofill":true,"totp":true,"submit":true'
else
grep -q -- '--browser-display-mode shown' "$local_capture"
grep -q '^autofill_click=0$' "$local_capture"
grep -q '^autofill_totp=0$' "$local_capture"
if [ "$local_case" = full ]; then
grep -q '^autofill_disable=1$' "$local_capture"
else
grep -q '^autofill_disable=0$' "$local_capture"
fi
fi
fi
done
local_status="$(PATH="$fake_login_keychain_bin:$PATH" LEMANA_VPN_USERNAME=lemana-local-otp LEMANA_VPN_LOCAL_OTP=1 bash "$ROOT/bin/vpn-lemanapro.sh" --status --json)"
printf '%s\n' "$local_status" | python3 -c 'import json,sys; m=json.load(sys.stdin)["modules"]; assert m["local_otp"] and not m["mode"]["ready"]; assert "--import-yandex-key" in m["mode"]["setup_hint"]'
fake_bw_sync_bin="$TMP_DIR/fake-bw-sync-bin"
mkdir -p "$fake_bw_sync_bin"
cat > "$fake_bw_sync_bin/bw" <<'SH'
#!/bin/sh
case "${1:-}:${2:-}" in
unlock:*)
printf 'fake-session\n'
;;
get:password)
printf 'ldap-password-from-bitwarden\n'
;;
get:item)
printf '{"login":{"username":"lemana-bw-user","totp":"otpauth://totp/Lemana:test?secret=abcd2345efgh6723&issuer=Lemana"}}\n'
;;
*)
exit 1
;;
esac
SH
cat > "$fake_bw_sync_bin/security" <<'SH'
#!/bin/sh
if [ "${1:-}" = "find-generic-password" ]; then
account=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "-a" ]; then
shift
account="${1:-}"
break
fi
shift
done
case "$account" in
lemana-bw-user|totp/lemana-bw-user) exit 0 ;;
*) exit 1 ;;
esac
fi
exit 0
SH
cat > "$fake_bw_sync_bin/keychain-fingerprint" <<'SH'
#!/bin/sh
if [ "${1:-}" = "get" ]; then
printf 'fake-bitwarden-master-password\n'
exit 0
fi
exit 1
SH
fake_store_python="$fake_bw_sync_bin/fake-oc-python"
cat > "$fake_store_python" <<'SH'
#!/bin/sh
if [ "$#" -eq 1 ] && [ "${1:-}" = "-" ] && [ -n "${LEMANA_VPN_CAPTURE_STORE:-}" ]; then
cat >/dev/null
{
printf 'username=%s\n' "${KC_USERNAME:-}"
printf 'password=%s\n' "${_VPN_PASS:-}"
printf 'totp=%s\n' "${_VPN_TOTP_SECRET:-}"
} > "$LEMANA_VPN_CAPTURE_STORE"
exit 0
fi
exec python3 "$@"
SH
chmod +x "$fake_bw_sync_bin/bw" "$fake_bw_sync_bin/security" "$fake_bw_sync_bin/keychain-fingerprint" "$fake_store_python"
bitwarden_username_home="$TMP_DIR/bitwarden-username-home"
bitwarden_username_capture="$TMP_DIR/bitwarden-username-capture"
bitwarden_store_capture="$TMP_DIR/bitwarden-store-capture"
mkdir -p "$bitwarden_username_home/.config/lemana-vpn" "$bitwarden_username_home/.config/openconnect-lite"
printf 'LEMANA_VPN_MODE="bitwarden-auto"\nLEMANA_VPN_USE_BITWARDEN="1"\nLEMANA_VPN_USE_TOUCHID="1"\n' > "$bitwarden_username_home/.config/lemana-vpn/env"
cat > "$bitwarden_username_home/.config/openconnect-lite/config.toml" <<'TOML'
on_disconnect = "true"
[default_profile]
address = "vpn.lemanapro.ru"
user_group = ""
name = "Default-K"
[credentials]
username = ""
TOML
bitwarden_username_output="$(
HOME="$bitwarden_username_home" \
PATH="$fake_bw_sync_bin:$PATH" \
LEMANA_VPN_CONFIG_DIR="$bitwarden_username_home/.config/lemana-vpn" \
OPENCONNECT_LITE_CONFIG_DIR="$bitwarden_username_home/.config/openconnect-lite" \
LEMANA_VPN_KEYCHAIN_FINGERPRINT="$fake_bw_sync_bin/keychain-fingerprint" \
LEMANA_VPN_OC_BIN="$fake_oc_bin" \
LEMANA_VPN_OC_PYTHON="$fake_store_python" \
LEMANA_VPN_WEBENGINE_PROCESS="$fake_webengine" \
LEMANA_VPN_AUTHENTICATOR="$fake_authenticator" \
LEMANA_VPN_PATCH_BACKUP_DIR="$TMP_DIR/bitwarden-username-patch-backups" \
LEMANA_VPN_DNS_CLEANUP="$TMP_DIR/fake-dns-cleanup" \
LEMANA_VPN_CONNECT_LOG="$TMP_DIR/bitwarden-username.log" \
LEMANA_VPN_CAPTURE_LAUNCH="$bitwarden_username_capture" \
LEMANA_VPN_CAPTURE_STORE="$bitwarden_store_capture" \
bash "$ROOT/bin/vpn-lemanapro.sh" --json
)"
printf '%s\n' "$bitwarden_username_output" | grep -q '"event":"bw_touchid"'
printf '%s\n' "$bitwarden_username_output" | grep -q '"event":"username_saved","source":"bitwarden"'
printf '%s\n' "$bitwarden_username_output" | grep -q '"event":"auto_sso","mode":"bitwarden-auto","autofill":true,"totp":true,"submit":true'
if printf '%s\n' "$bitwarden_username_output" | grep -q '"event":"username_required"'; then
echo "bitwarden-auto must read LDAP username from Bitwarden instead of prompting for it" >&2
exit 1
fi
grep -q '^username=lemana-bw-user$' "$bitwarden_store_capture"
grep -q '^password=ldap-password-from-bitwarden$' "$bitwarden_store_capture"
grep -q '^totp=ABCD2345EFGH6723$' "$bitwarden_store_capture"
grep -q 'LEMANA_VPN_USERNAME="lemana-bw-user"' "$bitwarden_username_home/.config/lemana-vpn/env"
grep -q 'username = "lemana-bw-user"' "$bitwarden_username_home/.config/openconnect-lite/config.toml"
grep -q -- '--browser-display-mode hidden' "$bitwarden_username_capture"
bitwarden_auto_capture="$TMP_DIR/bitwarden-auto-capture"
bitwarden_auto_output="$(
HOME="$HOME" \
PATH="$fake_path_bin:$PATH" \
LEMANA_VPN_USERNAME="lemana-saved-totp-$$" \
LEMANA_VPN_AUTH_PROFILE=saved-totp \
LEMANA_VPN_CREDENTIAL_SOURCE=keychain \
LEMANA_VPN_USERNAME="lemana-bitwarden-auto-$$" \
LEMANA_VPN_MODE=bitwarden-auto \
LEMANA_VPN_LOCAL_OTP=1 \
LEMANA_VPN_USE_TOUCHID=0 \
LEMANA_VPN_OC_BIN="$fake_oc_bin" \
LEMANA_VPN_OC_PYTHON=python3 \
LEMANA_VPN_WEBENGINE_PROCESS="$fake_webengine" \
LEMANA_VPN_AUTHENTICATOR="$fake_authenticator" \
LEMANA_VPN_PATCH_BACKUP_DIR="$TMP_DIR/saved-totp-patch-backups" \
LEMANA_VPN_DNS_CLEANUP="$TMP_DIR/no-dns-cleanup" \
LEMANA_VPN_CONNECT_LOG="$TMP_DIR/saved-totp.log" \
LEMANA_VPN_CAPTURE_LAUNCH="$saved_totp_capture" \
LEMANA_VPN_PATCH_BACKUP_DIR="$TMP_DIR/bitwarden-auto-patch-backups" \
LEMANA_VPN_DNS_CLEANUP="$TMP_DIR/fake-dns-cleanup" \
LEMANA_VPN_CONNECT_LOG="$TMP_DIR/bitwarden-auto.log" \
LEMANA_VPN_CAPTURE_LAUNCH="$bitwarden_auto_capture" \
bash "$ROOT/bin/vpn-lemanapro.sh" --json
)"
printf '%s\n' "$saved_totp_output" | grep -q '"event":"manual_sso","auth_profile":"saved-totp","autofill":true,"totp":true,"submit":false'
grep -q -- '--browser-display-mode shown' "$saved_totp_capture"
grep -q '^autofill_disable=0$' "$saved_totp_capture"
grep -q '^autofill_click=0$' "$saved_totp_capture"
grep -q '^autofill_totp=1$' "$saved_totp_capture"
printf '%s\n' "$bitwarden_auto_output" | grep -q '"event":"auto_sso","mode":"bitwarden-auto","autofill":true,"totp":true,"submit":true'
grep -q -- '--browser-display-mode hidden' "$bitwarden_auto_capture"
grep -q '^arg=--script$' "$bitwarden_auto_capture"
grep -Fq "arg=$TMP_DIR/fake-dns-cleanup --vpnc" "$bitwarden_auto_capture"
grep -q '^autofill_disable=0$' "$bitwarden_auto_capture"
grep -q '^autofill_click=1$' "$bitwarden_auto_capture"
grep -q '^autofill_totp=1$' "$bitwarden_auto_capture"
# A normal Connect has no mode argument and must use the last saved selection.
profile_bin="$TMP_DIR/profile-bin"
mkdir -p "$profile_bin"
printf '#!/bin/sh\nprintf '\''{"status":"locked"}\\n'\''\n' > "$profile_bin/bw"
chmod +x "$profile_bin/bw"
profile_home="$TMP_DIR/profiles"
mkdir -p "$profile_home/config"
printf 'LEMANA_VPN_USERNAME="fixture-profile"\nLEMANA_VPN_USE_TOUCHID="0"\n' > "$profile_home/config/env"
profile_cli() {
HOME="$profile_home" PATH="$profile_bin:$fake_path_bin:$PATH" \
LEMANA_VPN_CONFIG_DIR="$profile_home/config" \
LEMANA_VPN_OC_BIN="$fake_oc_bin" LEMANA_VPN_OC_PYTHON=python3 \
LEMANA_VPN_WEBENGINE_PROCESS="$fake_webengine" LEMANA_VPN_AUTHENTICATOR="$fake_authenticator" \
LEMANA_VPN_PATCH_BACKUP_DIR="$profile_home/patches" \
LEMANA_VPN_DNS_CLEANUP="$TMP_DIR/fake-dns-cleanup" \
LEMANA_VPN_CONNECT_LOG="$profile_home/connect.log" \
LEMANA_VPN_CAPTURE_LAUNCH="$profile_home/launch" \
bash "$ROOT/bin/vpn-lemanapro.sh" "$@"
}
for profile in manual keychain bitwarden; do
profile_cli "--set-auth=$profile" >/dev/null
profile_cli --json > "$profile_home/events"
if [ "$profile" = manual ]; then
grep -q -- '--browser-display-mode shown' "$profile_home/launch"
grep -q '^autofill_totp=0$' "$profile_home/launch"
grep -q '^autofill_click=0$' "$profile_home/launch"
else
grep -q -- '--browser-display-mode hidden' "$profile_home/launch"
grep -q '^autofill_totp=1$' "$profile_home/launch"
grep -q '^autofill_click=1$' "$profile_home/launch"
grep -q '"event":"auto_sso"' "$profile_home/events"
fi
done
manual_full_no_credentials_home="$TMP_DIR/manual-full-no-credentials-home"
manual_full_no_credentials_capture="$TMP_DIR/manual-full-no-credentials-capture"
@@ -594,13 +847,13 @@ manual_full_no_credentials_output="$(
LEMANA_VPN_WEBENGINE_PROCESS="$fake_webengine" \
LEMANA_VPN_AUTHENTICATOR="$fake_authenticator" \
LEMANA_VPN_PATCH_BACKUP_DIR="$TMP_DIR/manual-full-no-credentials-patch-backups" \
LEMANA_VPN_DNS_CLEANUP="$TMP_DIR/no-dns-cleanup" \
LEMANA_VPN_DNS_CLEANUP="$TMP_DIR/fake-dns-cleanup" \
LEMANA_VPN_CONNECT_LOG="$TMP_DIR/manual-full-no-credentials.log" \
LEMANA_VPN_CAPTURE_LAUNCH="$manual_full_no_credentials_capture" \
bash "$ROOT/bin/vpn-lemanapro.sh" --manual-full --json
)"
printf '%s\n' "$manual_full_no_credentials_output" | grep -q '"event":"manual_sso","auth_profile":"manual","autofill":false,"submit":false'
printf '%s\n' "$manual_full_no_credentials_output" | grep -q '"event":"manual_sso","mode":"manual-keychain","diagnostic":true,"autofill":false,"totp":false,"submit":false'
if printf '%s\n' "$manual_full_no_credentials_output" | grep -Eq '"event":"(username_required|keychain_required)"'; then
echo "manual-full should not require saved username or Keychain credentials before opening the browser" >&2
exit 1
@@ -640,7 +893,7 @@ spawn env \
LEMANA_VPN_WEBENGINE_PROCESS=$env(FAKE_WEBENGINE) \
LEMANA_VPN_AUTHENTICATOR=$env(FAKE_AUTHENTICATOR) \
LEMANA_VPN_PATCH_BACKUP_DIR=$env(TMP_DIR)/first-username-patch-backups \
LEMANA_VPN_DNS_CLEANUP=$env(TMP_DIR)/no-dns-cleanup \
LEMANA_VPN_DNS_CLEANUP=$env(TMP_DIR)/fake-dns-cleanup \
LEMANA_VPN_CONNECT_LOG=$env(TMP_DIR)/first-username.log \
LEMANA_VPN_CAPTURE_LAUNCH=$env(MANUAL_FULL_CAPTURE) \
bash $env(ROOT)/bin/vpn-lemanapro.sh --manual --json
@@ -663,13 +916,13 @@ manual_full_output="$(
LEMANA_VPN_WEBENGINE_PROCESS="$fake_webengine" \
LEMANA_VPN_AUTHENTICATOR="$fake_authenticator" \
LEMANA_VPN_PATCH_BACKUP_DIR="$TMP_DIR/manual-full-patch-backups" \
LEMANA_VPN_DNS_CLEANUP="$TMP_DIR/no-dns-cleanup" \
LEMANA_VPN_DNS_CLEANUP="$TMP_DIR/fake-dns-cleanup" \
LEMANA_VPN_CONNECT_LOG="$TMP_DIR/manual-full.log" \
LEMANA_VPN_CAPTURE_LAUNCH="$manual_full_capture" \
bash "$ROOT/bin/vpn-lemanapro.sh" --manual-full --json
)"
printf '%s\n' "$manual_full_output" | grep -q '"event":"manual_sso","auth_profile":"manual","autofill":false,"submit":false'
printf '%s\n' "$manual_full_output" | grep -q '"event":"manual_sso","mode":"manual-keychain","diagnostic":true,"autofill":false,"totp":false,"submit":false'
grep -q -- '--browser-display-mode shown' "$manual_full_capture"
grep -q '^autofill_disable=1$' "$manual_full_capture"
grep -q '^autofill_click=0$' "$manual_full_capture"
+294
View File
@@ -0,0 +1,294 @@
#!/usr/bin/env python3
"""Synthetic wizard/Keychain tests. Run with the openconnect-lite Python."""
import base64
import importlib.util
import json
import os
import fcntl
from pathlib import Path
import pty
import select
import subprocess
import sys
import tempfile
import termios
import time
sys.dont_write_bytecode = True
# The existing tests/logging.py is a test entry point, not stdlib logging.
sys.path = [p for p in sys.path if Path(p).resolve() != Path(__file__).resolve().parent]
from unittest.mock import patch
import pyotp
ROOT = Path(__file__).resolve().parents[1]
spec = importlib.util.spec_from_file_location("yandex_import", ROOT / "bin/lemana-vpn-import-yandex.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
SECRET = base64.b32encode(b"12345678901234567890").decode()
OTHER = "JBSWY3DPEHPK3PXP"
URI = f"otpauth://totp/Lemana:example?secret={SECRET}&algorithm=SHA1&digits=6&period=30"
ENTRY = {"name": "Lemana VPN", "secret": SECRET, "techInfo": URI}
ORIGINAL_CONFIG = 'LEMANA_VPN_USERNAME="example"\nLEMANA_VPN_MODE="bitwarden-auto"\nLEMANA_VPN_LOCAL_OTP="0"\nLEMANA_VPN_LOG_MAX_BYTES="12345"\n'
verify = pyotp.TOTP.verify
assert pyotp.TOTP(SECRET).at(59) == "287082" # RFC 6238 SHA1 vector, six digits.
class FakeKeyring:
def __init__(self, previous=OTHER):
self.values = {"example": "unchanged-password"}
if previous is not None:
self.values["totp/example"] = previous
self.writes = []
def get_password(self, service, account):
assert service == "openconnect-lite"
return self.values.get(account)
def set_password(self, service, account, value):
assert service == "openconnect-lite" and account == "totp/example"
self.writes.append((account, value))
self.values[account] = value
def delete_password(self, service, account):
assert service == "openconnect-lite" and account == "totp/example"
self.values.pop(account, None)
def wizard(directory, keyring, answers=None, entries=None, code="287082"):
backup = directory / "key_backup"
backup.write_text(json.dumps(entries if entries is not None else [
{"name": "Yandex account", "techInfo": "otpauth://yaotp/test?secret=not-supported"}, ENTRY]))
config = directory / "env"
config.write_text(ORIGINAL_CONFIG)
inputs = iter(answers if answers is not None else [str(backup), "2"])
output = []
def ask(prompt):
output.append(prompt)
try:
return next(inputs)
except StopIteration:
raise EOFError from None
def ask_secret(prompt):
assert any("LDAP: example" in line for line in output), output
assert any("Запись: Lemana VPN" in line for line in output), output
output.append(prompt)
if code is None:
raise EOFError
return code
failure = None
with patch.dict(sys.modules, {"keyring": keyring}), patch.object(pyotp.TOTP, "verify", lambda self, otp, valid_window: verify(self, otp, for_time=59, valid_window=valid_window)):
try:
module.configure("example", config, ask, ask_secret, output.append)
except (module.ImportFailure, EOFError, KeyboardInterrupt, OSError) as error:
failure = error
captured = "\n".join(output)
assert SECRET not in captured and OTHER not in captured and URI not in captured, captured
assert "unchanged-password" not in captured
return config, failure, output
with tempfile.TemporaryDirectory(prefix="lemana-yandex-") as temporary:
directory = Path(temporary)
keyring = FakeKeyring()
config, error, output = wizard(directory, keyring)
assert error is None, error
assert keyring.writes == [("totp/example", SECRET)], keyring.writes
assert keyring.values["example"] == "unchanged-password"
assert 'LEMANA_VPN_MODE="keychain-auto"' in config.read_text()
assert 'LEMANA_VPN_LOCAL_OTP="1"' in config.read_text()
assert 'LEMANA_VPN_LOG_MAX_BYTES="12345"' in config.read_text()
assert config.stat().st_mode & 0o777 == 0o600
assert not list(directory.glob(".otp-env-*"))
print("PASS wizard: mixed backup, explicit VPN selection, RFC code, one Keychain entry and private settings")
for answers, code in (([], "287082"), ([str(directory / "key_backup")], "287082"),
([str(directory / "key_backup"), ""], "287082"),
([str(directory / "key_backup"), "0"], "287082"),
(None, "000000"), (None, None)):
keyring = FakeKeyring()
config, error, _ = wizard(directory, keyring, answers=answers, code=code)
assert error is not None and keyring.writes == []
assert config.read_text() == ORIGINAL_CONFIG
print("PASS cancellation/invalid choice/wrong code: no writes")
variants = [URI.replace("//totp/", "//hotp/"), URI.replace("//totp/", "//yaotp/"),
URI.replace("SHA1", "SHA256"), URI.replace("digits=6", "digits=8"),
URI.replace("period=30", "period=60"), URI + "&secret=" + OTHER,
URI + "&algorithm=SHA1", URI + "&digits=6", URI + "&period=30",
URI.replace(SECRET, "NOT-BASE32"), URI.replace(SECRET, OTHER),
URI + "&counter=1", URI.replace("otpauth:", "https:")]
variants.extend([URI + "&encoder=steam", URI + "&chars=alpha&length=8"])
for uri in variants:
keyring = FakeKeyring()
config, error, _ = wizard(directory, keyring, entries=[dict(ENTRY, techInfo=uri)],
answers=[str(directory / "key_backup"), "1"])
assert isinstance(error, module.ImportFailure), (uri, error)
assert keyring.writes == [] and config.read_text() == ORIGINAL_CONFIG
assert SECRET not in str(error) and OTHER not in str(error)
print("PASS unsupported types/parameters, duplicate parameters, malformed/mismatched secrets")
for previous in (OTHER, None):
for failure in (OSError("secret backend detail"), KeyboardInterrupt()):
keyring = FakeKeyring(previous)
with patch.object(module.os, "replace", side_effect=failure):
config, error, _ = wizard(directory, keyring)
assert error is failure and config.read_text() == ORIGINAL_CONFIG
assert keyring.get_password("openconnect-lite", "totp/example") == previous
assert not list(directory.glob(".otp-env-*"))
keyring = FakeKeyring()
with patch.object(keyring, "set_password", side_effect=RuntimeError("backend secret")), patch.dict(sys.modules, {"keyring": keyring}):
try:
module.activate(SECRET, "example", config)
assert False, "expected backend failure"
except module.ImportFailure as error:
assert "вернуть прежний секрет не удалось" in str(error) and "backend secret" not in str(error)
assert config.read_text() == ORIGINAL_CONFIG
print("PASS config failure/interruption rollback and secret-safe rollback failure")
keyring = FakeKeyring()
real_replace = module.os.replace
def interrupted_after_replace(source, destination):
real_replace(source, destination)
raise KeyboardInterrupt
with patch.object(module.os, "replace", side_effect=interrupted_after_replace):
config, error, _ = wizard(directory, keyring)
assert isinstance(error, module.ImportFailure) and "уже сохранена" in str(error)
assert keyring.values["totp/example"] == SECRET and 'LEMANA_VPN_LOCAL_OTP="1"' in config.read_text()
keyring = FakeKeyring(None)
with patch.object(keyring, "set_password", return_value=None):
config, error, _ = wizard(directory, keyring)
assert isinstance(error, module.ImportFailure) and config.read_text() == ORIGINAL_CONFIG
keyring = FakeKeyring()
config.write_text(ORIGINAL_CONFIG.replace('USERNAME="example"', 'USERNAME="another"'))
with patch.dict(sys.modules, {"keyring": keyring}):
try:
module.activate(SECRET, "example", config)
assert False, "wrong saved account accepted"
except module.ImportFailure:
pass
assert not keyring.writes
config.write_text(ORIGINAL_CONFIG)
print("PASS commit-point consistency, silent backend failure and saved-account binding")
backup = directory / "bad_backup"
for data in (b"not JSON", b"{}", b"[]", b"[{\"techInfo\":1}]", b"x" * (module.MAX_BACKUP_BYTES + 1)):
backup.write_bytes(data)
try:
module.read_backup(backup)
assert False, "bad backup accepted"
except module.ImportFailure:
pass
assert module.display_name("hello\x1b[2J\nworld") == "hello[2Jworld"
assert module.display_name("A" * 110 + SECRET, {SECRET}) == ""
keyring = FakeKeyring()
config, error, output = wizard(directory, keyring, entries=[{"name": SECRET.lower(), "techInfo": URI}],
answers=[str(directory / "key_backup"), ""])
assert SECRET.lower() not in "\n".join(output) and not keyring.writes
config, error, output = wizard(directory, keyring, entries=[{
"name": SECRET, "secret": " " + SECRET + " ", "techInfo": URI.replace(SECRET, "%20" + SECRET + "%20")}],
answers=[str(directory / "key_backup"), ""])
assert SECRET not in "\n".join(output) and not keyring.writes
print("PASS malformed/oversized files and terminal-control sanitization")
# Installed entry point must fail without a controlling terminal, before writes.
sso = directory / "xdg/openconnect-lite"
sso.mkdir(parents=True)
sso.joinpath("config.toml").write_text('[credentials]\nusername="example"\n')
env = dict(os.environ, HOME=temporary, XDG_CONFIG_HOME=str(sso.parent), XDG_CONFIG_DIRS=str(sso.parent))
proc = subprocess.run([sys.executable, str(ROOT / "bin/lemana-vpn-import-yandex.py"),
"--username=example", "--config", str(config)],
stdin=subprocess.DEVNULL, capture_output=True, text=True, start_new_session=True, env=env)
assert proc.returncode == 1 and "Traceback" not in proc.stderr
assert config.read_text() == ORIGINAL_CONFIG
print("PASS no-terminal entry: bounded error, no writes")
# Drive the actual CLI + helper through an automated PTY and a fake backend.
fake = directory / "fake"
fake.mkdir()
fake.joinpath("security").write_text("#!/bin/sh\nexit 0\n")
fake.joinpath("security").chmod(0o755)
state = directory / "keyring.json"
state.write_text(json.dumps({"example": "unchanged-password"}))
fake.joinpath("keyring.py").write_text('''import json, os
from pathlib import Path
state = Path(os.environ["TEST_KEYRING"])
def get_password(service, account):
assert service == "openconnect-lite"
return json.loads(state.read_text()).get(account)
def set_password(service, account, password):
assert service == "openconnect-lite"
data = json.loads(state.read_text()); data[account] = password
state.write_text(json.dumps(data))
def delete_password(service, account):
data = json.loads(state.read_text()); data.pop(account, None)
state.write_text(json.dumps(data))
''')
directory.joinpath("key_backup").write_text(json.dumps([ENTRY]))
env = {key: value for key, value in env.items() if not key.startswith(("LEMANA_VPN_", "OPENCONNECT_LITE_"))}
env.update(PATH=str(fake) + ":" + os.environ["PATH"], PYTHONPATH=str(fake), TEST_KEYRING=str(state),
LEMANA_VPN_CONFIG_DIR=temporary, LEMANA_VPN_OC_PYTHON=sys.executable,
OPENCONNECT_LITE_CONFIG_DIR=str(sso), LEMANA_VPN_STATUS_DIR=str(directory / "status"))
master, slave = pty.openpty()
def controlling_terminal():
os.setsid()
fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
proc = subprocess.Popen(["/bin/bash", str(ROOT / "bin/vpn-lemanapro.sh"), "--import-yandex-key"],
stdin=slave, stdout=slave, stderr=slave, env=env, preexec_fn=controlling_terminal)
os.close(slave)
output = b""
phone_code = pyotp.TOTP(SECRET).now()
inputs = [("Путь к файлу", str(directory / "key_backup")), ("Номер записи", "1"),
("для проверки и сохранения:", phone_code)]
deadline = time.monotonic() + 15
try:
while time.monotonic() < deadline:
if select.select([master], [], [], max(0, deadline - time.monotonic()))[0]:
try:
chunk = os.read(master, 8192)
except OSError:
break # PTY EOF on macOS.
if not chunk:
break
output += chunk
if inputs and inputs[0][0].encode() in output:
os.write(master, (inputs.pop(0)[1] + "\n").encode())
if proc.poll() is not None:
break
assert proc.wait(timeout=2) == 0, output.decode(errors="replace")
finally:
os.close(master)
if proc.poll() is None:
proc.kill()
proc.wait()
assert not inputs and SECRET.encode() not in output and phone_code.encode() not in output
assert json.loads(state.read_text()) == {"example": "unchanged-password", "totp/example": SECRET}
status = subprocess.run(["bash", str(ROOT / "bin/vpn-lemanapro.sh"), "--status", "--json"],
env=env, text=True, capture_output=True, check=True)
modules = json.loads(status.stdout)["modules"]
assert modules["local_otp"] and modules["mode"]["ready"] and not modules["bitwarden"]["enabled"]
override = subprocess.run(["bash", str(ROOT / "bin/vpn-lemanapro.sh"), "--status", "--json"],
env=dict(env, LEMANA_VPN_MODE="manual-keychain", LEMANA_VPN_LOCAL_OTP="0"), text=True, capture_output=True, check=True)
assert not json.loads(override.stdout)["modules"]["local_otp"]
config_after = config.read_text()
stored_after = state.read_text()
runtime_definitions = (ROOT / "bin/vpn-lemanapro.sh").read_text().split('\nif $STATUS_MODE; then', 1)[0]
mismatch = subprocess.run(["bash", "-c", runtime_definitions + '\n_can_prompt() { return 0; }; _import_yandex_key'],
env=dict(env, LEMANA_VPN_USERNAME="different-user"), text=True, capture_output=True)
assert mismatch.returncode != 0 and "отличается" in mismatch.stderr
mismatch = subprocess.run([sys.executable, str(ROOT / "bin/lemana-vpn-import-yandex.py"),
"--username=different-user", "--config", str(config)],
env=env, text=True, capture_output=True)
assert mismatch.returncode != 0 and "профиле SSO" in mismatch.stderr
assert config.read_text() == config_after and state.read_text() == stored_after
print("PASS real CLI/PTY import → fake Keychain → persisted mode → status; hidden code; override and account guards")
print("yandex import checks ok")