Files
lemana-vpn/tests/menu.py
T

513 lines
33 KiB
Python

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