513 lines
33 KiB
Python
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")
|