Files
VoxVera/tests/test_cli.py
T

1243 lines
47 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
from voxvera.platforms import docker as platform_docker
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")
monkeypatch.delenv("VOXVERA_RUNTIME", raising=False)
monkeypatch.setattr(platform_docker.DockerPlatformAdapter, "is_container_runtime", lambda self: False)
adapter = get_platform_adapter(cli_module=cli)
assert adapter.platform_id == "linux_cli_systemd"
def test_get_platform_adapter_docker(monkeypatch):
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setenv("VOXVERA_RUNTIME", "docker")
monkeypatch.setattr(platform_docker.DockerPlatformAdapter, "is_container_runtime", lambda self: True)
adapter = get_platform_adapter(cli_module=cli)
assert adapter.platform_id == "docker_cli"
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_init_seeds_missing_default_config(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
test_data_dir = tmp_path / "data"
config_path = test_data_dir / "fresh-config.json"
answers = iter([
"Fresh Site",
"freshsite",
"Fresh Title",
"Fresh Subtitle",
"Fresh Headline",
"https://example.com",
"Read more",
"Footer bits",
"",
])
monkeypatch.setattr(cli, "choose_language", lambda *a: "en")
monkeypatch.setattr(cli, "prompt", lambda questions: {question["name"]: next(answers) for question in questions})
monkeypatch.setattr(cli, "open_editor", lambda initial: "Fresh content")
class _Confirm:
def execute(self):
return False
monkeypatch.setattr(cli.inquirer, "confirm", lambda **kwargs: _Confirm())
cli.main(["--config", str(config_path), "init"])
assert config_path.exists()
saved = cli.load_config(config_path)
assert saved["folder_name"] == "freshsite"
assert saved["content"] == "Fresh content"
def test_init_noninteractive_does_not_prompt_for_language(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
test_data_dir = tmp_path / "data"
config_path = test_data_dir / "fresh-config.json"
def _fail_choose_language(*args, **kwargs):
raise AssertionError("choose_language should not run for non-interactive init")
monkeypatch.setattr(cli, "choose_language", _fail_choose_language)
cli.main(["--config", str(config_path), "init", "--non-interactive"])
saved = cli.load_config(config_path)
assert saved["lang"] == "en"
assert (test_data_dir / "host" / saved["folder_name"] / "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_serve_recovers_onion_url_from_session_file(tmp_path, monkeypatch):
"""OnionShare 2.6 may not log the URL when using system Tor."""
_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
(dir_path / ".onionshare-session").write_text(
json.dumps({"general": {"service_id": "sessionserviceid"}}),
encoding="utf-8",
)
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)
class FakePopen:
def __init__(self, cmd, stdout=None, stderr=None, env=None, **kwargs):
self.pid = 43
if stdout is not None:
stdout.write("Onion.start_onion_service: key_type=ED25519-V3\n")
stdout.flush()
def poll(self):
return None
monkeypatch.setattr(cli.subprocess, "Popen", FakePopen)
cli.main(["serve"])
updated = json.load(open(dir_path / "config.json"))
assert updated["tear_off_link"] == "http://sessionserviceid.onion"
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 _linux_autostart_start" in service_text
assert "KillMode=process" 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_linux_autostart_start_sets_hardened_environment(monkeypatch):
events = []
monkeypatch.delenv("VOXVERA_ONIONSHARE_NO_AWAIT_PUBLICATION", raising=False)
monkeypatch.delenv("VOXVERA_ONIONSHARE_SYSTEM_TOR", raising=False)
monkeypatch.setattr(cli, "cleanup_onionshare_tmp", lambda max_age_minutes=60: events.append(("cleanup", max_age_minutes)) or 0)
monkeypatch.setattr(cli, "_user_is_group_member", lambda group: False)
monkeypatch.setattr(cli, "start_all_servers", lambda: events.append(("start_all", None)))
cli.linux_autostart_start()
assert events == [("cleanup", 60), ("start_all", None)]
assert os.environ["VOXVERA_ONIONSHARE_NO_AWAIT_PUBLICATION"] == "1"
assert os.environ["VOXVERA_ONIONSHARE_SYSTEM_TOR"] == "1"
def test_linux_autostart_start_switches_to_debian_tor_group(monkeypatch):
exec_call = {}
monkeypatch.setattr(cli, "cleanup_onionshare_tmp", lambda max_age_minutes=60: 0)
monkeypatch.setattr(cli, "_user_is_group_member", lambda group: group == "debian-tor")
monkeypatch.setattr(cli, "_current_group_names", lambda: {"user"})
monkeypatch.setattr(cli, "_find_voxvera_bin", lambda: "/tmp/vox vera")
def fake_execvp(binary, args):
exec_call["binary"] = binary
exec_call["args"] = args
raise RuntimeError("stop")
monkeypatch.setattr(cli.os, "execvp", fake_execvp)
with pytest.raises(RuntimeError, match="stop"):
cli.linux_autostart_start()
assert exec_call["binary"] == "sg"
assert exec_call["args"][:3] == ["sg", "debian-tor", "-c"]
assert "VOXVERA_ONIONSHARE_NO_AWAIT_PUBLICATION=1" in exec_call["args"][3]
assert "VOXVERA_ONIONSHARE_SYSTEM_TOR=1" in exec_call["args"][3]
assert "'/tmp/vox vera' start-all" in exec_call["args"][3]
def test_serve_uses_internal_onionshare_when_no_await_requested(tmp_path, monkeypatch):
_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
monkeypatch.setenv("VOXVERA_ONIONSHARE_NO_AWAIT_PUBLICATION", "1")
monkeypatch.setattr(cli, "require_cmd", lambda c: True)
monkeypatch.setattr(cli, "build_assets", lambda *args, **kwargs: None)
monkeypatch.setattr(time, "sleep", lambda x: None)
monkeypatch.setattr(cli.shutil, "which", lambda cmd: "/usr/bin/onionshare-cli" if cmd == "onionshare-cli" else None)
onion_url = "http://internalpath.onion"
popen_calls = []
class FakePopen:
def __init__(self, cmd, stdout=None, stderr=None, env=None, **kwargs):
popen_calls.append(cmd)
self.pid = 44
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)
cli.main(["serve"])
assert popen_calls
assert "_internal_onionshare" in popen_calls[0]
assert "/usr/bin/onionshare-cli" not in popen_calls[0]
updated = json.load(open(dir_path / "config.json"))
assert updated["tear_off_link"] == onion_url
def _write_nostr_event(path, payload_overrides=None, event_overrides=None):
payload = {
"type": "voxvera_flyer",
"version": 1,
"folder_name": "nostr-flyer",
"lang": "en",
"name": "Nostr Flyer",
"title": "PUBLIC NOTICE",
"subtitle": "DO ~~NOT~~ IGNORE",
"headline": "NOSTR SOURCE TEST",
"content": "This flyer came from a Nostr event.",
"url_message": "Read the source.",
"url": "https://example.com/source",
"footer_message": "npub test",
"attachment_path": "",
"attachment_filename": "",
"qr_target": "flyer_url",
}
if payload_overrides:
payload.update(payload_overrides)
event = {
"id": "f" * 64,
"pubkey": "a" * 64,
"created_at": 1760000000,
"kind": 30078,
"tags": [["d", "voxvera:nostr-flyer"], ["t", "voxvera"], ["t", "flyer"]],
"content": json.dumps(payload),
"sig": "b" * 128,
}
if event_overrides:
event.update(event_overrides)
path.write_text(json.dumps(event), encoding="utf-8")
return path
def test_nostr_validate_accepts_local_event_file(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
event_path = _write_nostr_event(tmp_path / "event.json")
cli.main(["nostr", "validate", str(event_path)])
assert "Valid VoxVera Nostr flyer: NOSTR SOURCE TEST" in capsys.readouterr().out
def test_nostr_import_writes_existing_config_schema(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
event_path = _write_nostr_event(tmp_path / "event.json")
cli.main(["nostr", "import", str(event_path)])
config_path = tmp_path / "data" / "host" / "nostr-flyer" / "config.json"
imported = json.loads(config_path.read_text(encoding="utf-8"))
assert imported["folder_name"] == "nostr-flyer"
assert imported["headline"] == "NOSTR SOURCE TEST"
assert imported["content"] == "This flyer came from a Nostr event."
assert imported["url"] == "https://example.com/source"
assert "Imported Nostr flyer to" in capsys.readouterr().out
def test_nostr_import_rejects_existing_site_without_overwrite(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
event_path = _write_nostr_event(tmp_path / "event.json")
cli.main(["nostr", "import", str(event_path)])
with pytest.raises(SystemExit):
cli.main(["nostr", "import", str(event_path)])
assert "already exists" in capsys.readouterr().err
def test_nostr_build_generates_existing_flyer_output(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
event_path = _write_nostr_event(tmp_path / "event.json")
cli.main(["nostr", "build", str(event_path)])
host_dir = tmp_path / "data" / "host" / "nostr-flyer"
assert (host_dir / "config.json").exists()
assert (host_dir / "index.html").exists()
html = (host_dir / "index.html").read_text(encoding="utf-8")
assert "NOSTR SOURCE TEST" in html
def test_nostr_serve_builds_then_uses_existing_serve_path(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
event_path = _write_nostr_event(tmp_path / "event.json")
served = []
monkeypatch.setattr(cli, "serve", lambda config_path: served.append(Path(config_path).name) or "http://ok.onion")
cli.main(["nostr", "serve", str(event_path)])
assert served == ["config.json"]
assert (tmp_path / "data" / "host" / "nostr-flyer" / "index.html").exists()
def test_nostr_validate_rejects_unsafe_url_scheme(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
event_path = _write_nostr_event(tmp_path / "event.json", {"url": "javascript:alert(1)"})
with pytest.raises(SystemExit):
cli.main(["nostr", "validate", str(event_path)])
assert "Unsupported URL scheme" in capsys.readouterr().err
def test_nostr_validate_rejects_raw_html(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
event_path = _write_nostr_event(tmp_path / "event.json", {"content": "<script>alert(1)</script>"})
with pytest.raises(SystemExit):
cli.main(["nostr", "validate", str(event_path)])
assert "contains raw HTML" in capsys.readouterr().err
def test_nostr_validate_rejects_non_file_identifier_in_phase_one(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
with pytest.raises(SystemExit):
cli.main(["nostr", "validate", "note1example"])
assert "Phase 1 only supports local Nostr event JSON files" in capsys.readouterr().err
def test_is_server_running_returns_false_for_unreachable_onion(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
test_data_dir = tmp_path / "data"
host_dir = test_data_dir / "host" / "voxvera"
host_dir.mkdir(parents=True, exist_ok=True)
(host_dir / "server.pid").write_text("123\n", encoding="utf-8")
(host_dir / "config.json").write_text(
json.dumps({"folder_name": "voxvera", "tear_off_link": "http://examplehiddenservice.onion"}),
encoding="utf-8",
)
old = time.time() - 1200
os.utime(host_dir / "server.pid", (old, old))
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(cli.os, "kill", lambda pid, sig: None)
monkeypatch.setattr(cli.subprocess, "check_output", lambda *args, **kwargs: b"onionshare-cli --website")
monkeypatch.setattr(cli.shutil, "which", lambda cmd: "/usr/bin/curl" if cmd == "curl" else None)
class _Result:
returncode = 97
stdout = ""
monkeypatch.setattr(cli.subprocess, "run", lambda *args, **kwargs: _Result())
assert cli.is_server_running("voxvera") is False
assert (host_dir / "server.pid").exists()
def test_is_server_running_keeps_recent_unreachable_onion_alive(tmp_path, monkeypatch):
_setup_tmp(monkeypatch, tmp_path)
test_data_dir = tmp_path / "data"
host_dir = test_data_dir / "host" / "voxvera"
host_dir.mkdir(parents=True, exist_ok=True)
(host_dir / "server.pid").write_text("123\n", encoding="utf-8")
(host_dir / "config.json").write_text(
json.dumps({"folder_name": "voxvera", "tear_off_link": "http://examplehiddenservice.onion"}),
encoding="utf-8",
)
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(cli.os, "kill", lambda pid, sig: None)
monkeypatch.setattr(cli.subprocess, "check_output", lambda *args, **kwargs: b"onionshare-cli --website")
monkeypatch.setattr(cli.shutil, "which", lambda cmd: "/usr/bin/curl" if cmd == "curl" else None)
class _Result:
returncode = 97
stdout = ""
monkeypatch.setattr(cli.subprocess, "run", lambda *args, **kwargs: _Result())
assert cli.is_server_running("voxvera") is True
def test_start_all_servers_restarts_unhealthy_site(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
host_dir = tmp_path / "data" / "host" / "voxvera"
host_dir.mkdir(parents=True, exist_ok=True)
(host_dir / "config.json").write_text(json.dumps({"folder_name": "voxvera"}), encoding="utf-8")
(host_dir / "server.pid").write_text("123\n", encoding="utf-8")
events = []
monkeypatch.setattr(cli, "_seed_bundled_sites", lambda: None)
monkeypatch.setattr(cli, "is_server_running", lambda name: False)
monkeypatch.setattr(cli, "stop_server", lambda name: events.append(("stop", name)))
monkeypatch.setattr(cli, "serve", lambda path: events.append(("serve", Path(path).name)) or "http://ok.onion")
cli.start_all_servers()
assert events == [("stop", "voxvera"), ("serve", "config.json")]
output = capsys.readouterr().out
assert "[RESTART] voxvera: existing process failed health check" in output
assert "[OK] voxvera: http://ok.onion" in output
def test_stop_server_kills_process_group_on_linux(tmp_path, monkeypatch, capsys):
_setup_tmp(monkeypatch, tmp_path)
host_dir = tmp_path / "data" / "host" / "voxvera"
host_dir.mkdir(parents=True, exist_ok=True)
pid_file = host_dir / "server.pid"
pid_file.write_text("456\n", encoding="utf-8")
killed = []
monkeypatch.setattr(platform, "system", lambda: "Linux")
monkeypatch.setattr(cli.os, "killpg", lambda pid, sig: killed.append((pid, sig)))
cli.stop_server("voxvera")
assert killed == [(456, cli.signal.SIGTERM)]
assert not pid_file.exists()
assert "Stopped voxvera (PID 456)" in capsys.readouterr().out
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()