Chromium kiosk without a desktop for the Raspberry Pi 3, a Flask web interface on port 8080 (kiosk, system and network management), a restricted root helper, network changes with automatic rollback, a healthcheck timer, install/update/uninstall scripts and a pi-gen image build. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
202 lines
8.5 KiB
Python
202 lines
8.5 KiB
Python
import json
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
from voxip_kiosk import helper as h
|
|
from voxip_kiosk.config import load_config
|
|
|
|
|
|
class Recorder:
|
|
def __init__(self, outputs=None):
|
|
self.commands = []
|
|
self.outputs = outputs or {}
|
|
|
|
def __call__(self, args, timeout=30, check=True, input=None):
|
|
self.commands.append(list(args))
|
|
key = " ".join(args[:3])
|
|
return subprocess.CompletedProcess(args, 0, stdout=self.outputs.get(key, ""), stderr="")
|
|
|
|
def ran(self, *prefix):
|
|
return [c for c in self.commands if c[: len(prefix)] == list(prefix)]
|
|
|
|
|
|
@pytest.fixture
|
|
def boot(tmp_path):
|
|
d = tmp_path / "boot"
|
|
d.mkdir()
|
|
(d / "config.txt").write_text("")
|
|
return d
|
|
|
|
|
|
@pytest.fixture
|
|
def helper(paths, tmp_path, boot):
|
|
etc = tmp_path / "sysetc"
|
|
etc.mkdir()
|
|
(etc / "hosts").write_text("127.0.0.1\tlocalhost\n127.0.1.1\tkiosk\n")
|
|
run = Recorder({"nmcli -t -f": "aaaa-1111\n"})
|
|
return h.Helper(paths=paths, run=run, etc=etc, boot_dirs=(boot,), helper_path="/usr/lib/voxip-kiosk/voxip-kiosk-helper")
|
|
|
|
|
|
def run_main(helper, argv, env=None, capsys=None):
|
|
code = h.main(argv, helper=helper, environ=env or {})
|
|
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
|
return code, out
|
|
|
|
|
|
def test_internal_verbs_refused_via_sudo(helper, capsys):
|
|
code, out = run_main(helper, ["network-restore"], {"SUDO_USER": "voxip"}, capsys)
|
|
assert code == 1 and out["ok"] is False
|
|
code, out = run_main(helper, ["boot-import"], {"SUDO_USER": "voxip"}, capsys)
|
|
assert code == 1
|
|
assert helper.run.commands == []
|
|
|
|
|
|
def test_public_verb_via_sudo(helper, capsys):
|
|
code, out = run_main(helper, ["restart-browser"], {"SUDO_USER": "voxip"}, capsys)
|
|
assert code == 0 and out["ok"] is True
|
|
assert helper.run.ran("systemctl", "restart", "--no-block", "voxip-kiosk-browser.service")
|
|
|
|
|
|
def test_unknown_verb_and_bad_arity(helper, capsys):
|
|
assert run_main(helper, ["rm", "-rf", "/"], capsys=capsys)[0] == 1
|
|
assert run_main(helper, ["reboot", "now"], capsys=capsys)[0] == 1
|
|
assert run_main(helper, ["set-hostname"], capsys=capsys)[0] == 1
|
|
assert helper.run.commands == []
|
|
|
|
|
|
def test_requires_root_without_injected_helper(capsys, monkeypatch):
|
|
monkeypatch.setattr(h.os, "geteuid", lambda: 1000)
|
|
assert h.main(["reboot"], environ={}) == 1
|
|
assert "root" in capsys.readouterr().out
|
|
|
|
|
|
def test_set_hostname(helper, capsys):
|
|
code, out = run_main(helper, ["set-hostname", "Hal-BG"], capsys=capsys)
|
|
assert code == 0 and out["hostname"] == "hal-bg"
|
|
assert (helper.etc / "hostname").read_text() == "hal-bg\n"
|
|
hosts = (helper.etc / "hosts").read_text()
|
|
assert "127.0.1.1\thal-bg" in hosts and "127.0.1.1\tkiosk" not in hosts and "localhost" in hosts
|
|
assert helper.run.ran("hostname", "hal-bg")
|
|
|
|
|
|
@pytest.mark.parametrize("bad", ["kiosk; reboot", "../etc", "$(id)", "-x", ""])
|
|
def test_set_hostname_rejects_injection(helper, capsys, bad):
|
|
code, out = run_main(helper, ["set-hostname", bad], capsys=capsys)
|
|
assert code == 1
|
|
assert helper.run.commands == []
|
|
|
|
|
|
def test_reboot_is_delayed_through_systemd_run(helper):
|
|
helper.reboot()
|
|
assert helper.run.commands[0][:2] == ["systemd-run", "--on-active=3"]
|
|
assert helper.run.commands[0][-2:] == ["/bin/systemctl", "reboot"]
|
|
|
|
|
|
ETH_STATIC = {"interface": "eth0", "method": "static", "address": "192.168.5.10/24", "gateway": "192.168.5.1", "dns": ["192.168.5.1"]}
|
|
|
|
|
|
def test_network_apply_snapshot_pending_and_timers(helper, paths):
|
|
(paths.nm_connections_dir / "Wired connection 1.nmconnection").write_text("[connection]\nid=Wired connection 1\n")
|
|
result = helper.network_apply(ETH_STATIC)
|
|
keyfile = paths.nm_connections_dir / "voxip-eth0.nmconnection"
|
|
assert keyfile.exists() and oct(keyfile.stat().st_mode & 0o777) == "0o600"
|
|
assert "address1=192.168.5.10/24,192.168.5.1" in keyfile.read_text()
|
|
assert (paths.network_backup_dir / "Wired connection 1.nmconnection").exists()
|
|
pending = json.loads(paths.network_pending_file.read_text())
|
|
assert pending["active_before"] == ["aaaa-1111"]
|
|
assert pending["deadline"] - pending["created"] == 180
|
|
assert result["pending"]["interface"] == "eth0"
|
|
runs = [c for c in helper.run.commands if c[0] == "systemd-run"]
|
|
assert runs[0][1:3] == ["--unit=voxip-network-rollback", "--on-active=180"] and runs[0][-1] == "network-restore"
|
|
assert runs[1][1:3] == ["--unit=voxip-network-activate", "--on-active=2"] and runs[1][-2:] == ["network-activate", "eth0"]
|
|
|
|
|
|
def test_second_apply_refused_while_pending(helper):
|
|
helper.network_apply(ETH_STATIC)
|
|
with pytest.raises(h.HelperError, match="open"):
|
|
helper.network_apply(ETH_STATIC)
|
|
|
|
|
|
def test_confirm_clears_pending(helper, paths):
|
|
helper.network_apply(ETH_STATIC)
|
|
helper.network_confirm()
|
|
assert not paths.network_pending_file.exists()
|
|
assert not paths.network_backup_dir.exists()
|
|
assert (paths.nm_connections_dir / "voxip-eth0.nmconnection").exists()
|
|
assert helper.run.ran("systemctl", "stop", "voxip-network-rollback.timer")
|
|
|
|
|
|
def test_restore_puts_old_profiles_back(helper, paths):
|
|
old = paths.nm_connections_dir / "Wired connection 1.nmconnection"
|
|
old.write_text("[connection]\nid=Wired connection 1\n")
|
|
helper.network_apply(ETH_STATIC)
|
|
old.unlink()
|
|
helper.network_restore()
|
|
assert old.exists()
|
|
assert not (paths.nm_connections_dir / "voxip-eth0.nmconnection").exists()
|
|
assert not paths.network_pending_file.exists()
|
|
assert helper.run.ran("nmcli", "--wait", "30", "connection", "up", "uuid", "aaaa-1111")
|
|
|
|
|
|
def test_public_rollback_is_asynchronous(helper):
|
|
helper.network_apply(ETH_STATIC)
|
|
helper.run.commands.clear()
|
|
helper.network_rollback()
|
|
runs = [c for c in helper.run.commands if c[0] == "systemd-run"]
|
|
assert runs and runs[0][2] == "--on-active=1" and runs[0][-1] == "network-restore"
|
|
|
|
|
|
def test_wifi_keeps_existing_password_for_same_ssid(helper, paths):
|
|
helper.network_apply({"interface": "wlan0", "method": "dhcp", "wifi": {"ssid": "Kantoor", "psk": "geheim-wachtwoord"}})
|
|
helper.network_confirm()
|
|
helper.network_apply({"interface": "wlan0", "method": "static", "address": "10.0.0.9/24", "gateway": "10.0.0.1", "dns": "10.0.0.1",
|
|
"wifi": {"ssid": "Kantoor", "psk": ""}})
|
|
assert "psk=geheim-wachtwoord" in (paths.nm_connections_dir / "voxip-wlan0.nmconnection").read_text()
|
|
helper.network_confirm()
|
|
with pytest.raises(h.HelperError, match="wachtwoord"):
|
|
helper.network_apply({"interface": "wlan0", "method": "dhcp", "wifi": {"ssid": "Ander", "psk": ""}})
|
|
|
|
|
|
def test_boot_guard_rolls_back_and_honours_reset(helper, paths, boot):
|
|
old = paths.nm_connections_dir / "Wired connection 1.nmconnection"
|
|
old.write_text("x")
|
|
helper.network_apply(ETH_STATIC)
|
|
helper.run.commands.clear()
|
|
result = helper.network_boot_guard()
|
|
assert result == {"rolled_back": True}
|
|
assert not (paths.nm_connections_dir / "voxip-eth0.nmconnection").exists()
|
|
assert not any(c[0] == "nmcli" for c in helper.run.commands), "NetworkManager is not running yet at boot"
|
|
|
|
(paths.nm_connections_dir / "voxip-wlan0.nmconnection").write_text("x")
|
|
(boot / "voxip-network-reset").write_text("")
|
|
assert helper.network_boot_guard() == {"reset": True}
|
|
assert not (paths.nm_connections_dir / "voxip-wlan0.nmconnection").exists()
|
|
assert not (boot / "voxip-network-reset").exists()
|
|
assert old.exists()
|
|
|
|
|
|
def test_boot_import_applies_config(helper, paths, boot):
|
|
(boot / "voxip-config.json").write_text(json.dumps({"device_name": "Hal", "url": "https://klant.example.nl/display/hal?token=x",
|
|
"hostname": "hal", "rotation": 90}))
|
|
assert helper.boot_import() == {"imported": ["voxip-config.json"]}
|
|
conf = load_config(paths.config_file)
|
|
assert conf["device_name"] == "Hal" and conf["rotation"] == 90 and conf["url"].startswith("https://klant")
|
|
assert (helper.etc / "hostname").read_text() == "hal\n"
|
|
assert (boot / "voxip-config.json.applied").exists()
|
|
|
|
|
|
def test_boot_import_rejects_invalid_config(helper, paths, boot):
|
|
before = paths.config_file.read_text()
|
|
(boot / "voxip-config.json").write_text(json.dumps({"url": "http://onveilig.example"}))
|
|
helper.boot_import()
|
|
assert paths.config_file.read_text() == before
|
|
assert (boot / "voxip-config.json.rejected").exists()
|
|
assert "HTTPS" in (boot / "voxip-config.error.txt").read_text()
|
|
|
|
|
|
def test_boot_import_rejects_non_key_lines(helper, boot):
|
|
(boot / "voxip-authorized_keys").write_text("dit is geen sleutel\n")
|
|
helper.boot_import()
|
|
assert (boot / "voxip-authorized_keys.rejected").exists()
|