Files
VoxVera/tests/test_cli.py
T

838 lines
32 KiB
Python

import os
import sys
import shutil
import json
import zipfile
import time
import socket
import platform
from pathlib import Path
import pytest
from voxvera import cli
from voxvera.platforms import get_platform_adapter
from voxvera.platforms import base as platform_base
from voxvera.platforms import linux as platform_linux
from voxvera.platforms import macos as platform_macos
from voxvera.platforms import windows as platform_windows
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
def _setup_tmp(monkeypatch, tmp_path):
repo_root = Path(__file__).resolve().parent.parent
# Use a unique subdirectory for this test run to ensure isolation
test_data_dir = tmp_path / "data"
test_data_dir.mkdir(exist_ok=True)
# Internal resources (ROOT)
res_root = test_data_dir / "voxvera"
res_root.mkdir()
shutil.copytree(repo_root / "voxvera" / "src", res_root / "src")
shutil.copytree(repo_root / "voxvera" / "locales", res_root / "locales")
shutil.copytree(repo_root / "voxvera" / "templates", res_root / "templates")
shutil.copy(repo_root / "support-matrix.json", test_data_dir / "support-matrix.json")
monkeypatch.setattr(cli, "ROOT", res_root)
monkeypatch.setattr(cli, "DATA_DIR", test_data_dir)
monkeypatch.setattr(cli, "_src_res", lambda *p: res_root / "src" / Path(*p))
monkeypatch.setattr(cli, "_locale_res", lambda *p: res_root / "locales" / Path(*p))
monkeypatch.setattr(cli, "_template_res", lambda *p: res_root / "templates" / Path(*p))
# Create a default config.json in DATA_DIR
shutil.copy(res_root / "src" / "config.json", test_data_dir / "config.json")
return repo_root
def test_help(capsys):
with pytest.raises(SystemExit):
cli.main(["-h"])
captured = capsys.readouterr()
assert "usage:" in captured.out
def test_get_platform_adapter_linux(monkeypatch):
monkeypatch.setattr(platform, "system", lambda: "Linux")
adapter = get_platform_adapter(cli_module=cli)
assert adapter.platform_id == "linux_cli_systemd"
def test_get_platform_adapter_macos(monkeypatch):
monkeypatch.setattr(platform, "system", lambda: "Darwin")
adapter = get_platform_adapter(cli_module=cli)
assert adapter.platform_id == "macos_cli"
def test_get_platform_adapter_windows(monkeypatch):
monkeypatch.setattr(platform, "system", lambda: "Windows")
adapter = get_platform_adapter(cli_module=cli)
assert adapter.platform_id == "windows_cli"
def test_init_template(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
test_data_dir = tmp_path / "data"
# Mock choose_language to return 'en'
monkeypatch.setattr(cli, "choose_language", lambda *a: "en")
cli.main(["init", "--template", "voxvera"])
dest = test_data_dir / "host" / "voxvera"
assert dest.is_dir()
assert (dest / "config.json").exists()
assert (dest / "index.html").exists()
def test_create_from_template_persists_current_language(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
cli.load_locale("es")
class _Prompt:
def execute(self):
return cli.list_presets()[0]
monkeypatch.setattr(cli.inquirer, "select", lambda **kwargs: _Prompt())
monkeypatch.setattr(cli, "interactive_update", lambda path: None)
monkeypatch.setattr(cli, "resolve_new_site_folder", lambda path: cli.load_config(path)["folder_name"])
monkeypatch.setattr(cli, "build_assets", lambda path: None)
monkeypatch.setattr(cli, "serve", lambda path: None)
cli.create_from_template(None)
config = cli.load_config(tmp_path / "data" / "config.json")
assert config["lang"] == "es"
def test_resolve_new_site_folder_versions_existing_folder(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
test_data_dir = tmp_path / "data"
host_dir = test_data_dir / "host"
existing_dir = host_dir / "jesus"
existing_dir.mkdir(parents=True)
(existing_dir / "config.json").write_text(json.dumps({"folder_name": "jesus"}), encoding="utf-8")
config_path = test_data_dir / "config.json"
config_path.write_text(json.dumps({"folder_name": "jesus"}), encoding="utf-8")
class _Prompt:
def execute(self):
return "version"
monkeypatch.setattr(cli.inquirer, "select", lambda **kwargs: _Prompt())
resolved = cli.resolve_new_site_folder(str(config_path))
updated = cli.load_config(config_path)
assert resolved == "jesus_01"
assert updated["folder_name"] == "jesus_01"
def test_build(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
cli.main(["build"])
test_data_dir = tmp_path / "data"
dest = test_data_dir / "host" / "voxvera"
assert dest.is_dir()
assert (dest / "config.json").exists()
assert (dest / "index.html").exists()
def test_build_with_download(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
download_file = tmp_path / "sample.zip"
download_file.write_text("dummy")
cli.main(["build", "--download", str(download_file)])
test_data_dir = tmp_path / "data"
dest = test_data_dir / "host" / "voxvera" / "download" / "extra-content.zip"
assert dest.is_file()
def test_build_generates_tear_off_qr_from_url_when_onion_missing(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
test_data_dir = tmp_path / "data"
config_path = test_data_dir / "config.json"
config = cli.load_config(config_path)
config["url"] = "https://example.com/learn-more"
config["tear_off_link"] = ""
cli.save_config(config, config_path)
cli.build_assets(str(config_path))
dest = test_data_dir / "host" / config["folder_name"]
html = (dest / "index.html").read_text(encoding="utf-8")
assert (dest / "qrcode-content.png").exists()
assert (dest / "qrcode-tear-offs.png").exists()
assert 'class="container no-tear-offs"' not in html
assert "https://example.com/learn-more" in html
def test_import(tmp_path, monkeypatch):
repo = _setup_tmp(monkeypatch, tmp_path)
test_data_dir = tmp_path / "data"
imports_dir = test_data_dir / "imports"
imports_dir.mkdir(parents=True, exist_ok=True)
base_data = json.load(open(repo / "imports" / "example.json"))
for sub in ["foo", "bar"]:
data = dict(base_data)
data["folder_name"] = sub
with open(imports_dir / f"{sub}.json", "w") as fh:
json.dump(data, fh)
cli.main(["batch-import"])
for sub in ["foo", "bar"]:
test_data_dir = tmp_path / "data"
dest = test_data_dir / "host" / sub
assert dest.is_dir()
assert (dest / "index.html").exists()
def test_import_preserves_session(tmp_path, monkeypatch):
"""Re-importing a config must not destroy the OnionShare session key."""
repo = _setup_tmp(monkeypatch, tmp_path)
test_data_dir = tmp_path / "data"
imports_dir = test_data_dir / "imports"
imports_dir.mkdir(parents=True, exist_ok=True)
base_data = json.load(open(repo / "imports" / "example.json"))
base_data["folder_name"] = "keep"
with open(imports_dir / "keep.json", "w") as fh:
json.dump(base_data, fh)
# first import creates the host dir
cli.main(["batch-import"])
test_data_dir = tmp_path / "data"
host_dir = test_data_dir / "host" / "keep"
assert host_dir.is_dir()
# simulate OnionShare having written a session key
session_file = host_dir / ".onionshare-session"
session_file.write_text("FAKE-KEY-DATA")
# re-import the same config
cli.main(["batch-import"])
# session key must still be present and unchanged
assert session_file.exists()
assert session_file.read_text() == "FAKE-KEY-DATA"
# built assets should still be there too
assert (host_dir / "index.html").exists()
def test_import_rejects_zip_path_traversal(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
malicious_zip = tmp_path / "malicious.zip"
with zipfile.ZipFile(malicious_zip, "w") as zf:
zf.writestr("../escape.txt", "owned")
zf.writestr("config.json", json.dumps({"folder_name": "evil"}))
cli.import_site(str(malicious_zip))
captured = capsys.readouterr()
assert "Refusing to extract path outside destination" in captured.out
assert not (tmp_path / "escape.txt").exists()
assert not ((tmp_path / "data" / "host" / "evil").exists())
def test_load_config_migrates_binary_message_to_footer_message(tmp_path):
config_path = tmp_path / "legacy-config.json"
config_path.write_text(json.dumps({"binary_message": "0101", "folder_name": "legacy"}), encoding="utf-8")
loaded = cli.load_config(str(config_path))
assert loaded["footer_message"] == "0101"
assert "binary_message" not in loaded
def test_check_all_present(capsys, monkeypatch):
monkeypatch.setattr(shutil, "which", lambda cmd: "/usr/bin/" + cmd)
cli.main(["check"])
captured = capsys.readouterr()
assert "All dependencies are installed." in captured.out
def test_check_missing(capsys, monkeypatch):
import builtins
original_import = builtins.__import__
def fake_import(name, *args, **kwargs):
if name == "onionshare_cli":
raise ImportError("Mocked missing")
return original_import(name, *args, **kwargs)
monkeypatch.setattr(builtins, "__import__", fake_import)
cli.main(["check"])
captured = capsys.readouterr()
assert "onionshare-cli" in captured.out
assert "missing" in captured.out
def test_doctor_command_outputs_platform_report(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(shutil, "which", lambda cmd: f"/usr/bin/{cmd}" if cmd in {"voxvera", "tor", "onionshare-cli", "systemctl"} else None)
class FakeSocket:
def settimeout(self, value):
return None
def connect_ex(self, addr):
return 0
def close(self):
return None
def fake_check_output(args, stderr=None):
if "is-enabled" in args:
return b"enabled"
if "is-active" in args:
return b"active"
raise AssertionError(args)
monkeypatch.setattr(platform_linux.socket, "socket", lambda *args, **kwargs: FakeSocket())
monkeypatch.setattr(platform_base.subprocess, "check_output", fake_check_output)
cli.main(["doctor"])
captured = capsys.readouterr()
assert "VoxVera Doctor" in captured.out
assert "linux_cli_systemd" in captured.out
def test_doctor_json_output(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(shutil, "which", lambda cmd: f"/usr/bin/{cmd}" if cmd in {"voxvera", "tor", "onionshare-cli"} else None)
class FakeSocket:
def settimeout(self, value):
return None
def connect_ex(self, addr):
return 0
def close(self):
return None
monkeypatch.setattr(platform_linux.socket, "socket", lambda *args, **kwargs: FakeSocket())
monkeypatch.setattr(platform_base.subprocess, "check_output", lambda *args, **kwargs: b"disabled")
cli.main(["doctor", "--json"])
captured = capsys.readouterr()
report = json.loads(captured.out)
assert report["platform_id"] == "linux_cli_systemd"
assert "summary" in report
assert "sections" in report
assert "dependencies" in report["sections"]
assert "checks" in report
def test_platform_status_json_output(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
monkeypatch.setattr(platform, "system", lambda: "Linux")
cli.main(["platform-status", "--json"])
captured = capsys.readouterr()
report = json.loads(captured.out)
assert report["platform_id"] == "linux_cli_systemd"
assert report["hosting_model"]
assert "checks" not in report
def test_autostart_status_command_outputs_timer_state(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
fake_home = tmp_path / "home"
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(platform_base.Path, "home", lambda: fake_home)
systemd_dir = fake_home / ".config" / "systemd" / "user"
systemd_dir.mkdir(parents=True, exist_ok=True)
(systemd_dir / "voxvera-start.service").write_text("service", encoding="utf-8")
(systemd_dir / "voxvera-start.timer").write_text("timer", encoding="utf-8")
def fake_check_output(args, stderr=None):
if "is-enabled" in args:
return b"enabled"
if "is-active" in args:
return b"active"
raise AssertionError(args)
monkeypatch.setattr(shutil, "which", lambda cmd: f"/usr/bin/{cmd}" if cmd == "systemctl" else None)
monkeypatch.setattr(platform_base.subprocess, "check_output", fake_check_output)
cli.main(["autostart", "status"])
captured = capsys.readouterr()
assert "Autostart Status" in captured.out
assert "systemd_timer_enabled" in captured.out
def test_autostart_status_json_output(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
fake_home = tmp_path / "home"
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(platform_base.Path, "home", lambda: fake_home)
systemd_dir = fake_home / ".config" / "systemd" / "user"
systemd_dir.mkdir(parents=True, exist_ok=True)
(systemd_dir / "voxvera-start.service").write_text("service", encoding="utf-8")
(systemd_dir / "voxvera-start.timer").write_text("timer", encoding="utf-8")
monkeypatch.setattr(shutil, "which", lambda cmd: f"/usr/bin/{cmd}" if cmd == "systemctl" else None)
monkeypatch.setattr(platform_base.subprocess, "check_output", lambda *args, **kwargs: b"enabled")
cli.main(["autostart", "status", "--json"])
captured = capsys.readouterr()
status = json.loads(captured.out)
assert status["platform_id"] == "linux_cli_systemd"
assert len(status["artifacts"]) == 2
def test_linux_autostart_uninstall_removes_artifacts(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
fake_home = tmp_path / "home"
commands = []
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(platform_linux.Path, "home", lambda: fake_home)
monkeypatch.setattr(platform_linux.subprocess, "run", lambda args, **kwargs: commands.append(args))
systemd_dir = fake_home / ".config" / "systemd" / "user"
systemd_dir.mkdir(parents=True, exist_ok=True)
service = systemd_dir / "voxvera-start.service"
timer = systemd_dir / "voxvera-start.timer"
service.write_text("service", encoding="utf-8")
timer.write_text("timer", encoding="utf-8")
cli.main(["autostart", "uninstall"])
captured = capsys.readouterr()
assert "Removed systemd user service" in captured.out
assert not service.exists()
assert not timer.exists()
assert ["systemctl", "--user", "disable", "--now", "voxvera-start.timer"] in commands
def test_macos_autostart_status_and_render(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
fake_home = tmp_path / "home"
monkeypatch.setattr(platform, "system", lambda: "Darwin")
monkeypatch.setattr(platform_base.Path, "home", lambda: fake_home)
adapter = get_platform_adapter(cli_module=cli)
assert adapter.platform_id == "macos_cli"
plist = adapter.render_launchd_plist("/tmp/voxvera")
assert "org.voxvera.start-all" in plist
assert "<string>/tmp/voxvera</string>" in plist
assert "start-all" in plist
status = adapter.autostart_status()
assert status["platform_id"] == "macos_cli"
assert status["artifacts"] == [str(fake_home / "Library" / "LaunchAgents" / "org.voxvera.start-all.plist")]
assert any(check["name"] == "launchd_loaded" for check in status["checks"])
def test_macos_autostart_uninstall_removes_plist(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
fake_home = tmp_path / "home"
commands = []
monkeypatch.setattr(platform, "system", lambda: "Darwin")
monkeypatch.setattr(platform_base.Path, "home", lambda: fake_home)
monkeypatch.setattr(platform_base.subprocess, "check_output", lambda args, stderr=None: commands.append(args) or b"")
plist = fake_home / "Library" / "LaunchAgents" / "org.voxvera.start-all.plist"
plist.parent.mkdir(parents=True, exist_ok=True)
plist.write_text("plist", encoding="utf-8")
cli.main(["autostart", "uninstall"])
captured = capsys.readouterr()
assert "Removed LaunchAgent" in captured.out
assert not plist.exists()
assert ["launchctl", "unload", str(plist)] in commands
def test_windows_autostart_status_and_render(monkeypatch):
monkeypatch.setattr(platform, "system", lambda: "Windows")
monkeypatch.setattr(platform_base.subprocess, "check_output", lambda *args, **kwargs: b"TaskName: VoxVera Start All")
adapter = get_platform_adapter(cli_module=cli)
assert adapter.platform_id == "windows_cli"
command = adapter.render_schtasks_create_command("C:\\voxvera.exe")
assert command[:6] == ["schtasks", "/Create", "/F", "/SC", "ONLOGON", "/TN"]
assert "VoxVera Start All" in command
assert '"C:\\voxvera.exe" start-all' in command
status = adapter.autostart_status()
assert status["platform_id"] == "windows_cli"
assert any(check["name"] == "scheduled_task_present" for check in status["checks"])
def test_windows_autostart_uninstall_uses_schtasks(monkeypatch, capsys):
commands = []
monkeypatch.setattr(platform, "system", lambda: "Windows")
monkeypatch.setattr(platform_base.subprocess, "check_output", lambda args, stderr=None: commands.append(args) or b"SUCCESS")
cli.main(["autostart", "uninstall"])
captured = capsys.readouterr()
assert "removed" in captured.out.lower()
assert ["schtasks", "/Delete", "/TN", "VoxVera Start All", "/F"] in commands
def test_build_download_zip(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
res_root = tmp_path / "data" / "voxvera"
test_data_dir = tmp_path / "data"
config = json.load(open(res_root / "src" / "config.json"))
folder_name = config["folder_name"]
zip_path = tmp_path / "file.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("dummy.txt", "data")
cli.main(["build", "--download", str(zip_path)])
dest = test_data_dir / "host" / folder_name / "download" / "extra-content.zip"
assert dest.is_file()
def test_serve_updates_url(tmp_path, monkeypatch):
"""Test that serve() auto-generates onion URL for tear-off links."""
_setup_tmp(monkeypatch, tmp_path)
cli.main(["build"])
res_root = tmp_path / "data" / "voxvera"
test_data_dir = tmp_path / "data"
config = json.load(open(res_root / "src" / "config.json"))
folder_name = config["folder_name"]
dir_path = test_data_dir / "host" / folder_name
# Set a custom URL that should be preserved (content link)
config["url"] = "https://example.com/external-resource"
with open(dir_path / "config.json", "w") as f:
json.dump(config, f)
# No need for TOR_* env vars anymore - auto-detection should work
monkeypatch.setattr(cli, "require_cmd", lambda c: True)
orig_build = cli.build_assets
def safe_build_assets(cfg, download_path=None):
dest = cli.ROOT / "host" / json.load(open(cfg))["folder_name"] / "config.json"
if Path(cfg) == dest:
return
return orig_build(cfg, download_path=download_path)
monkeypatch.setattr(cli, "build_assets", safe_build_assets)
monkeypatch.setattr(time, "sleep", lambda x: None)
onion_url = "http://test123.onion"
class FakePopen:
def __init__(self, cmd, stdout=None, stderr=None, env=None, **kwargs):
self.cmd = cmd
self.stdout = stdout
self.stderr = stderr
self.env = env
self.pid = 42
if stdout is not None:
stdout.write(f"Started at {onion_url}\n")
stdout.flush()
def poll(self):
return None
monkeypatch.setattr(cli.subprocess, "Popen", FakePopen)
cli.main(["serve"])
log_file = dir_path / "onionshare.log"
assert onion_url in log_file.read_text()
updated = json.load(open(dir_path / "config.json"))
# URL should be preserved (user-configurable content link)
assert updated["url"] == "https://example.com/external-resource"
# tear_off_link should be set to the onion address (for tear-off tabs)
assert updated["tear_off_link"] == onion_url
def test_quickstart_noninteractive(tmp_path, monkeypatch):
"""Test quickstart builds and serves in one command."""
_setup_tmp(monkeypatch, tmp_path)
res_root = tmp_path / "data" / "voxvera"
test_data_dir = tmp_path / "data"
config = json.load(open(res_root / "src" / "config.json"))
folder_name = config["folder_name"]
# No need for TOR_* env vars anymore - auto-detection should work
monkeypatch.setattr(cli, "require_cmd", lambda c: True)
orig_build = cli.build_assets
def safe_build_assets(cfg, download_path=None):
dest = cli.ROOT / "host" / json.load(open(cfg))["folder_name"] / "config.json"
if Path(cfg) == dest:
return
return orig_build(cfg, download_path=download_path)
monkeypatch.setattr(cli, "build_assets", safe_build_assets)
monkeypatch.setattr(time, "sleep", lambda x: None)
onion_url = "http://quick.onion"
class FakePopen:
def __init__(self, cmd, stdout=None, stderr=None, env=None, **kwargs):
self.cmd = cmd
self.stdout = stdout
self.stderr = stderr
self.env = env
self.pid = 99
if stdout is not None:
stdout.write(f"URL: {onion_url}\n")
stdout.flush()
def poll(self):
return None
monkeypatch.setattr(cli.subprocess, "Popen", FakePopen)
monkeypatch.setattr(cli, "choose_language", lambda *a: "en")
config_path = test_data_dir / "config.json"
cli.main(["quickstart", "--non-interactive"])
dir_path = test_data_dir / "host" / folder_name
assert (dir_path / "index.html").exists()
log_file = dir_path / "onionshare.log"
assert onion_url in log_file.read_text()
updated = json.load(open(dir_path / "config.json"))
# tear_off_link should be set to onion address (for tear-off tabs)
assert updated["tear_off_link"] == onion_url
# url should be preserved (user-configured content link)
assert updated["url"] == config["url"]
def test_install_systemd_autostart_writes_recovery_timer(tmp_path, monkeypatch):
"""Test Linux autostart installs a recurring recovery service and timer."""
commands = []
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(platform_linux.Path, "home", lambda: tmp_path)
monkeypatch.setattr(cli, "_find_voxvera_bin", lambda: "/tmp/voxvera")
monkeypatch.setattr(platform_linux.subprocess, "run", lambda args, **kwargs: commands.append(args))
monkeypatch.setenv("USER", "tester")
cli._install_systemd_autostart()
service_dir = tmp_path / ".config" / "systemd" / "user"
service_text = (service_dir / "voxvera-start.service").read_text(encoding="utf-8")
timer_text = (service_dir / "voxvera-start.timer").read_text(encoding="utf-8")
assert "ExecStart=/tmp/voxvera start-all" in service_text
assert "OnUnitActiveSec=5min" in timer_text
assert "Persistent=true" in timer_text
assert any(args[:4] == ["systemctl", "--user", "enable", "--now"] and args[4] == "voxvera-start.timer" for args in commands)
assert any(args[:4] == ["systemctl", "--user", "start", "voxvera-start.service"] for args in commands)
def test_get_tor_ports_with_env_vars(monkeypatch):
"""Test that get_tor_ports respects environment variables."""
monkeypatch.setenv("TOR_SOCKS_PORT", "9999")
monkeypatch.setenv("TOR_CONTROL_PORT", "8888")
socks, ctl = cli.get_tor_ports()
assert socks == "9999"
assert ctl == "8888"
def test_get_tor_ports_auto_detect_defaults(monkeypatch):
"""Test that get_tor_ports returns defaults when no env vars and no Tor running."""
monkeypatch.delenv("TOR_SOCKS_PORT", raising=False)
monkeypatch.delenv("TOR_CONTROL_PORT", raising=False)
# Mock socket to always fail connection so it falls back to defaults
class MockSocket:
def __init__(self, *args, **kwargs):
pass
def settimeout(self, *args, **kwargs):
pass
def connect_ex(self, *args, **kwargs):
return 1 # 1 is a failure code
def close(self):
pass
monkeypatch.setattr(socket, "socket", MockSocket)
# Don't actually start Tor, so detection will fail and fall back to defaults
socks, ctl = cli.get_tor_ports()
assert socks == "9050" # Default SOCKS port
assert ctl == "9051" # Default control port
def test_build_without_url_skips_qr(tmp_path, monkeypatch, capsys):
"""Test that build works even without URLs configured initially."""
_setup_tmp(monkeypatch, tmp_path)
# Remove URLs from config
res_root = tmp_path / "data" / "voxvera"
test_data_dir = tmp_path / "data"
config = json.load(open(res_root / "src" / "config.json"))
config["url"] = ""
config["tear_off_link"] = ""
with open(test_data_dir / "config.json", "w") as f:
json.dump(config, f)
# Remove any existing QR files from src and host to test fresh generation
for qr_file in ["qrcode-content.png", "qrcode-tear-offs.png"]:
(res_root / "src" / qr_file).unlink(missing_ok=True)
(test_data_dir / "host" / config["folder_name"] / qr_file).unlink(missing_ok=True)
# Build should complete without error, just skip QR generation
cli.main(["build"])
test_data_dir = tmp_path / "data"
dest = test_data_dir / "host" / config["folder_name"]
assert dest.is_dir()
assert (dest / "config.json").exists()
assert (dest / "index.html").exists()
html = (dest / "index.html").read_text(encoding="utf-8")
assert 'class="container no-tear-offs"' in html
# QR codes should not exist since no URLs were provided
assert not (dest / "qrcode-content.png").exists()
assert not (dest / "qrcode-tear-offs.png").exists()
def test_build_injects_localized_tor_browser_download_links(tmp_path, monkeypatch):
"""Test that the localized Tor Browser CTA gets the expected localized URL."""
_setup_tmp(monkeypatch, tmp_path)
res_root = tmp_path / "data" / "voxvera"
test_data_dir = tmp_path / "data"
config = json.load(open(res_root / "src" / "config.json"))
config["lang"] = "de"
with open(test_data_dir / "config.json", "w") as f:
json.dump(config, f)
cli.main(["--config", str(test_data_dir / "config.json"), "build"])
html = (test_data_dir / "host" / config["folder_name"] / "index.html").read_text(encoding="utf-8")
assert 'href="https://www.torproject.org/de/download/"' in html
assert "Tor-Browser verwenden" in html
def test_build_with_only_url(tmp_path, monkeypatch):
"""Test that the main URL also populates tear-off rendering before onion setup."""
_setup_tmp(monkeypatch, tmp_path)
# Set only url (content link)
res_root = tmp_path / "data" / "voxvera"
test_data_dir = tmp_path / "data"
config = json.load(open(res_root / "src" / "config.json"))
config["url"] = "https://example.com/external-resource"
config["tear_off_link"] = ""
with open(test_data_dir / "config.json", "w") as f:
json.dump(config, f)
# Remove any existing QR files from src and host to test fresh generation
for qr_file in ["qrcode-content.png", "qrcode-tear-offs.png"]:
(res_root / "src" / qr_file).unlink(missing_ok=True)
(test_data_dir / "host" / config["folder_name"] / qr_file).unlink(missing_ok=True)
cli.main(["build"])
test_data_dir = tmp_path / "data"
dest = test_data_dir / "host" / config["folder_name"]
html = (dest / "index.html").read_text(encoding="utf-8")
assert (dest / "qrcode-content.png").exists()
assert (dest / "qrcode-tear-offs.png").exists()
assert 'class="container no-tear-offs"' not in html
assert "https://example.com/external-resource" in html
def test_build_with_only_tear_off_link(tmp_path, monkeypatch):
"""Test that QR codes work when only tear_off_link is set (pre-serve)."""
_setup_tmp(monkeypatch, tmp_path)
# Set only tear_off_link (before serve sets it to onion)
res_root = tmp_path / "data" / "voxvera"
test_data_dir = tmp_path / "data"
config = json.load(open(res_root / "src" / "config.json"))
config["url"] = ""
config["tear_off_link"] = "http://preconfigured.onion"
with open(test_data_dir / "config.json", "w") as f:
json.dump(config, f)
# Remove any existing QR files from src and host to test fresh generation
for qr_file in ["qrcode-content.png", "qrcode-tear-offs.png"]:
(res_root / "src" / qr_file).unlink(missing_ok=True)
(test_data_dir / "host" / config["folder_name"] / qr_file).unlink(missing_ok=True)
cli.main(["build"])
test_data_dir = tmp_path / "data"
dest = test_data_dir / "host" / config["folder_name"]
html = (dest / "index.html").read_text(encoding="utf-8")
assert 'class="container no-tear-offs"' not in html
# Only tear-off QR should exist (will be updated by serve)
assert not (dest / "qrcode-content.png").exists()
assert (dest / "qrcode-tear-offs.png").exists()
def test_serve_passes_tor_ports_to_env(monkeypatch, tmp_path):
"""Test that serve() passes detected Tor ports via env to subprocess."""
_setup_tmp(monkeypatch, tmp_path)
cli.main(["build"])
captured_env = {}
class FakePopen:
def __init__(self, cmd, stdout=None, stderr=None, env=None, **kwargs):
self.cmd = cmd
self.stdout = stdout
self.stderr = stderr
self.pid = 42
captured_env.update(env or {})
if stdout is not None:
stdout.write("Started at http://test.onion\n")
stdout.flush()
def poll(self):
return None
monkeypatch.setattr(cli, "require_cmd", lambda c: True)
monkeypatch.setattr(cli.subprocess, "Popen", FakePopen)
monkeypatch.setattr(cli, "build_assets", lambda *a, **kw: None)
monkeypatch.setattr(time, "sleep", lambda x: None)
# Test with custom env vars
monkeypatch.setenv("TOR_SOCKS_PORT", "1111")
monkeypatch.setenv("TOR_CONTROL_PORT", "2222")
cli.main(["serve"])
assert captured_env.get("TOR_SOCKS_PORT") == "1111"
assert captured_env.get("TOR_CONTROL_PORT") == "2222"
def test_backward_compatibility_with_https_config(tmp_path, monkeypatch):
"""Test that old configs with https URLs still work for building."""
_setup_tmp(monkeypatch, tmp_path)
# Simulate old config with HTTPS URLs
res_root = tmp_path / "data" / "voxvera"
test_data_dir = tmp_path / "data"
config = json.load(open(res_root / "src" / "config.json"))
config["url"] = "https://example.com/old-style"
config["tear_off_link"] = "https://example.com/tear-off"
with open(test_data_dir / "config.json", "w") as f:
json.dump(config, f)
cli.main(["build"])
test_data_dir = tmp_path / "data"
dest = test_data_dir / "host" / config["folder_name"]
assert (dest / "qrcode-content.png").exists()
assert (dest / "qrcode-tear-offs.png").exists()
def test_long_urls_in_qr(tmp_path, monkeypatch):
"""Test that long URLs still generate valid QR codes."""
_setup_tmp(monkeypatch, tmp_path)
res_root = tmp_path / "data" / "voxvera"
test_data_dir = tmp_path / "data"
config = json.load(open(res_root / "src" / "config.json"))
# Very long URL (close to 200 char limit)
config["url"] = "http://" + "a" * 190 + ".onion"
config["tear_off_link"] = "https://example.com/" + "path/" * 20
with open(test_data_dir / "config.json", "w") as f:
json.dump(config, f)
cli.main(["build"])
test_data_dir = tmp_path / "data"
dest = test_data_dir / "host" / config["folder_name"]
assert (dest / "qrcode-content.png").exists()
assert (dest / "qrcode-tear-offs.png").exists()