Improve VPN status reporting and recovery
This commit is contained in:
@@ -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')
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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")
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user