#!/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.. "$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")