VoxIP Kiosk OS 1.0.0: Raspberry Pi kiosk with web management
CI / test-bookworm (push) Failing after 11s
CI / test-latest (push) Successful in 1m27s

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>
This commit is contained in:
rothmanandClaude Opus 5.5 committed 2026-11-04 21:38:49 +01:00
commit a33c4822bf
96 files changed
+7535

No files matched your search

View File
Whitespace-only changes.
+142
View File
@@ -0,0 +1,142 @@
import json
import re
from pathlib import Path
import pytest
from voxip_kiosk.paths import Paths
ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture
def paths(tmp_path) -> Paths:
p = Paths(
config_dir=tmp_path / "etc",
state_dir=tmp_path / "state",
network_state_dir=tmp_path / "netstate",
agent_run_dir=tmp_path / "run-agent",
browser_run_dir=tmp_path / "run-browser",
health_run_dir=tmp_path / "run-health",
share_dir=tmp_path / "share",
log_dir=tmp_path / "log",
helper=tmp_path / "helper",
nm_connections_dir=tmp_path / "nm",
)
for d in (p.config_dir, p.state_dir, p.agent_run_dir, p.browser_run_dir, p.health_run_dir, p.share_dir / "www",
p.log_dir, p.nm_connections_dir):
d.mkdir(parents=True)
(p.share_dir / "VERSION").write_text("9.9.9-test\n")
default = json.loads((ROOT / "config" / "config.default.json").read_text())
p.config_file.write_text(json.dumps(default))
return p
class FakeHelper:
def __init__(self):
self.calls = []
self.fail = None
self.responses = {}
def call(self, verb, *args, payload=None, timeout=60):
from voxip_kiosk.helper_client import HelperError
self.calls.append((verb, args, payload))
if self.fail:
raise HelperError(self.fail)
return {"ok": True, "message": f"{verb} ok", **self.responses.get(verb, {})}
class FakeCDP:
def __init__(self):
self.calls = []
self.fail = None
def _do(self, name, *args):
from voxip_kiosk.cdp import CDPError
self.calls.append((name, args))
if self.fail:
raise CDPError(self.fail)
def reload(self, ignore_cache=False):
self._do("reload")
def screenshot(self, quality=70):
self._do("screenshot")
return b"\xff\xd8fakejpeg"
@pytest.fixture
def fake_helper():
return FakeHelper()
@pytest.fixture
def fake_cdp():
return FakeCDP()
@pytest.fixture
def quiet_system(monkeypatch):
"""Keep the web tests independent of the machine they run on."""
from voxip_kiosk import netinfo, services, sysinfo
monkeypatch.setattr(services, "summary", lambda: {
"browser": {"unit": services.BROWSER_UNIT, "status": "ok", "label": "Actief", "restarts": "2", "since": ""},
"x": {"unit": services.X_UNIT, "status": "ok", "label": "Actief", "restarts": "0", "since": ""},
"agent": {"unit": services.AGENT_UNIT, "status": "ok", "label": "Actief", "restarts": "0", "since": ""},
"manager": {"unit": services.MANAGER_UNIT, "status": "ok", "label": "Actief", "restarts": "0", "since": ""},
})
monkeypatch.setattr(services, "recent_errors", lambda lines=20: [])
monkeypatch.setattr(services, "journal", lambda source, lines=100, priority=None: f"log:{source}:{lines}:{priority}")
monkeypatch.setattr(sysinfo, "ip_addresses", lambda: {"eth0": ["192.168.10.20/24"]})
monkeypatch.setattr(sysinfo, "throttled", lambda: {"raw": "0x50000", "now": False, "ever": True, "flags": ["Onderspanning opgetreden"]})
monkeypatch.setattr(netinfo, "overview", lambda: {
"available": True, "wifi_country": "NL",
"devices": {
iface: {"interface": iface, "kind": kind, "present": True, "state": "connected", "state_label": "Verbonden",
"connected": True, "connection": f"voxip-{iface}", "addresses": ["192.168.10.20/24"], "gateway": "192.168.10.1",
"dns": ["192.168.10.1"], "hwaddr": "B8:27:EB:00:00:01",
"profile": {"exists": True, "method": "dhcp", "address": "", "gateway": "", "dns": "", "ssid": "Kantoor", "hidden": False}}
for iface, kind in (("eth0", "ethernet"), ("wlan0", "wifi"))
},
})
@pytest.fixture
def app(paths, fake_helper, fake_cdp, quiet_system):
from voxip_kiosk.web import create_app
application = create_app(paths, helper=fake_helper, cdp=fake_cdp)
application.config["TESTING"] = True
return application
@pytest.fixture
def client(app):
return app.test_client()
def csrf_from(response) -> str:
match = re.search(r'name="csrf-token" content="([^"]+)"', response.get_data(as_text=True))
assert match, "csrf meta tag missing"
return match.group(1)
PASSWORD = "Correct-Paard-Batterij-42"
@pytest.fixture
def logged_in(app, client, paths):
"""A client with a configured admin password and an active session."""
app.extensions["voxip"].credentials.set_password(PASSWORD)
page = client.get("/login")
token = csrf_from(page)
resp = client.post("/login", data={"password": PASSWORD, "csrf_token": token})
assert resp.status_code == 302 and resp.headers["Location"].endswith("/")
return client
def token(client) -> str:
return csrf_from(client.get("/"))
+171
View File
@@ -0,0 +1,171 @@
import json
import pytest
from voxip_kiosk.agent import Agent, HUNG_RELOAD_AFTER, HUNG_RESTART_AFTER
from voxip_kiosk.cdp import CDPError
from voxip_kiosk.config import save_config
class Clock:
def __init__(self):
self.t = 1000.0
def __call__(self):
return self.t
class BrowserStub:
def __init__(self, url):
self.url = url
self.navigations = []
self.reloads = 0
self.closed = False
self.responsive_answer = True
self.down = False
def main_page(self):
if self.down:
raise CDPError("down")
return {"url": self.url, "webSocketDebuggerUrl": "ws://127.0.0.1:9222/devtools/page/1"}
def new_page(self, url):
self.url = url
def navigate(self, url):
self.navigations.append(url)
self.url = url
def reload(self, ignore_cache=False):
self.reloads += 1
def responsive(self, ws=None, timeout=5):
return self.responsive_answer
def close_browser(self):
self.closed = True
class Probe:
def __init__(self):
self.ok = True
self.reason = ""
self.calls = 0
def __call__(self, url):
self.calls += 1
return self.ok, self.reason
@pytest.fixture
def env(paths):
paths.admin_file.write_text("{}") # setup finished
clock = Clock()
probe = Probe()
start = f"file://{paths.www_dir}/start.html?next=x"
browser = BrowserStub(start)
agent = Agent(paths, cdp=browser, probe_fn=probe, clock=clock, wall=clock, hostname=lambda: "kiosk",
addresses=lambda: {"eth0": ["192.168.1.20/24"], "wlan0": ["fe80::1/64"]})
return agent, browser, probe, clock, paths
def test_navigates_from_start_page_when_reachable(env):
agent, browser, probe, clock, paths = env
agent.tick()
assert browser.navigations == ["https://schermen-ijsselflat.voxip.nl"]
state = json.loads(paths.agent_state_file.read_text())
assert state["page"] == "start" and state["target_reachable"] is True
def test_offline_page_when_unreachable_then_recovers(env):
agent, browser, probe, clock, _ = env
probe.ok, probe.reason = False, "Naam niet gevonden (DNS)"
agent.tick()
assert "offline.html" in browser.url and "reason=Naam+niet+gevonden" in browser.url
navs = len(browser.navigations)
clock.t += 5
agent.tick() # still offline: no re-navigation (probe cached / page already offline)
assert len(browser.navigations) == navs
probe.ok = True
clock.t += 11
agent.tick()
assert browser.url == "https://schermen-ijsselflat.voxip.nl"
def test_chromium_error_page_is_replaced(env):
agent, browser, probe, clock, _ = env
browser.url = "chrome-error://chromewebdata/"
probe.ok = False
agent.tick()
assert "offline.html" in browser.url
def test_running_page_is_left_alone_when_network_drops(env):
"""Pages with their own offline cache (e.g. InfoFlow displays) must keep running."""
agent, browser, probe, clock, _ = env
browser.url = "https://schermen-ijsselflat.voxip.nl"
agent.tick()
probe.ok = False
for _ in range(20):
clock.t += 61
agent.tick()
assert browser.navigations == []
assert browser.reloads == 0
def test_url_change_navigates_without_restart(env):
agent, browser, probe, clock, paths = env
browser.url = "https://schermen-ijsselflat.voxip.nl"
agent.tick()
save_config(paths.config_file, {"url": "https://nieuw.example.nl/scherm"})
agent.tick()
assert browser.navigations == ["https://nieuw.example.nl/scherm"]
assert not browser.closed
def test_refresh_interval_skips_when_unreachable(env):
agent, browser, probe, clock, paths = env
save_config(paths.config_file, {"refresh_interval": 60})
browser.url = "https://schermen-ijsselflat.voxip.nl"
agent.tick()
clock.t += 61
agent.tick()
assert browser.reloads == 1
probe.ok = False
clock.t += 61
agent.tick()
assert browser.reloads == 1
assert "overgeslagen" in agent.state["last_error"]["message"]
def test_hung_renderer_reload_then_browser_restart(env):
agent, browser, probe, clock, _ = env
browser.url = "https://schermen-ijsselflat.voxip.nl"
browser.responsive_answer = False
for _ in range(HUNG_RELOAD_AFTER):
agent.tick()
assert browser.reloads == 1 and not browser.closed
for _ in range(HUNG_RESTART_AFTER - HUNG_RELOAD_AFTER):
agent.tick()
assert browser.closed
def test_setup_page_until_password_exists(env):
agent, browser, probe, clock, paths = env
paths.admin_file.unlink()
paths.setup_code_file.write_text("ABCD-EFGH\n")
agent.tick()
assert "setup.html" in browser.url
assert "code=ABCD-EFGH" in browser.url and "ips=192.168.1.20" in browser.url and "port=8080" in browser.url
agent.tick()
assert len(browser.navigations) == 1 # no flicker: setup page is not reloaded every tick
paths.admin_file.write_text("{}")
agent.tick()
assert browser.url == "https://schermen-ijsselflat.voxip.nl"
def test_devtools_down_is_reported(env):
agent, browser, probe, clock, paths = env
browser.down = True
agent.tick()
assert json.loads(paths.agent_state_file.read_text())["browser"] == "unreachable"
+80
View File
@@ -0,0 +1,80 @@
import json
from pathlib import Path
from voxip_kiosk import browser as b
from voxip_kiosk.config import DEFAULTS
XRANDR = """Screen 0: minimum 320 x 200, current 1920 x 1080, maximum 7680 x 7680
HDMI-1 connected primary 1920x1080+0+0 (normal left inverted right x axis y axis) 531mm x 299mm
1920x1080 60.00*+ 50.00 59.94
1280x720 60.00 50.00 59.94
720x576 50.00
HDMI-2 disconnected (normal left inverted right x axis y axis)
"""
def test_build_args_never_weakens_tls_and_binds_devtools_locally(tmp_path):
args = b.build_args("/usr/bin/chromium", dict(DEFAULTS), tmp_path / "p", tmp_path / "c", "file:///start.html", (1920, 1080))
joined = " ".join(args)
for flag in b.FORBIDDEN_FLAGS:
assert flag not in joined
assert "--remote-debugging-address=127.0.0.1" in args
assert "--kiosk" in args and "--noerrdialogs" in args and "--disable-session-crashed-bubble" in args
assert "--window-size=1920,1080" in args
assert args[-1] == "file:///start.html"
assert not any(a.startswith("--force-device-scale-factor") for a in args)
def test_build_args_zoom(tmp_path):
args = b.build_args("/usr/bin/chromium", dict(DEFAULTS, zoom=1.5), tmp_path, tmp_path, "x")
assert "--force-device-scale-factor=1.5" in args
def test_parse_xrandr():
outputs = b.parse_xrandr(XRANDR)
assert len(outputs) == 1
out = outputs[0]
assert out["name"] == "HDMI-1"
assert out["modes"] == ["1920x1080", "1280x720", "720x576"]
assert out["current"] == "1920x1080@60.00"
assert out["preferred"] == "1920x1080"
assert out["size"] == (1920, 1080)
def test_xrandr_args_rotation_and_resolution():
out = b.parse_xrandr(XRANDR)[0]
assert b.xrandr_args(out, dict(DEFAULTS, rotation=90)) == ["xrandr", "--output", "HDMI-1", "--auto", "--rotate", "right"]
assert b.xrandr_args(out, dict(DEFAULTS, rotation=270, resolution="1280x720@50")) == [
"xrandr", "--output", "HDMI-1", "--mode", "1280x720", "--rate", "50", "--rotate", "left"]
# unsupported mode falls back to auto instead of failing
assert "--auto" in b.xrandr_args(out, dict(DEFAULTS, resolution="3840x2160"))
def test_xset_commands():
assert ["xset", "-dpms"] in b.xset_commands(dict(DEFAULTS))
cmds = b.xset_commands(dict(DEFAULTS, screen_always_on=False, screen_off_minutes=15))
assert ["xset", "+dpms"] in cmds and ["xset", "dpms", "0", "0", "900"] in cmds
def test_clean_profile_removes_locks_and_crash_flag(tmp_path):
profile = tmp_path / "profile"
(profile / "Default").mkdir(parents=True)
(profile / "SingletonLock").symlink_to("kiosk-1234")
(profile / "Default" / "Preferences").write_text(json.dumps({"profile": {"exit_type": "Crashed", "exited_cleanly": False}, "x": 1}))
b.clean_profile(profile)
assert not (profile / "SingletonLock").is_symlink()
prefs = json.loads((profile / "Default" / "Preferences").read_text())
assert prefs["profile"] == {"exit_type": "Normal", "exited_cleanly": True}
assert prefs["x"] == 1
def test_start_page_url_contains_target(paths):
url = b.start_page_url(paths, dict(DEFAULTS, url="https://e.org/x?token=abc", device_name="Hal 1"))
assert url.startswith(f"file://{paths.www_dir}/start.html?")
assert "next=https%3A%2F%2Fe.org%2Fx%3Ftoken%3Dabc" in url and "name=Hal+1" in url
def test_local_pages_exist():
www = Path(__file__).resolve().parents[1] / "www"
for page in ("start.html", "offline.html", "setup.html", "kiosk.css", "logo.svg"):
assert (www / page).exists()
+95
View File
@@ -0,0 +1,95 @@
import json
import os
import stat
import pytest
from voxip_kiosk import config as c
def test_default_file_matches_defaults(paths):
assert c.load_config(paths.config_file) == c.DEFAULTS
# the default config file itself must be valid
c.validate(json.loads(paths.config_file.read_text()))
def test_missing_file_gives_defaults(tmp_path):
assert c.load_config(tmp_path / "nope.json") == c.DEFAULTS
def test_invalid_values_fall_back_per_key(paths, caplog):
paths.config_file.write_text(json.dumps({"url": "http://insecure", "rotation": 45, "device_name": "Hal", "zoom": "x"}))
conf = c.load_config(paths.config_file)
assert conf["url"] == c.DEFAULTS["url"]
assert conf["rotation"] == 0
assert conf["zoom"] == 1.0
assert conf["device_name"] == "Hal"
assert "Ongeldige waarde" in caplog.text
def test_corrupt_file_uses_backup(paths):
c.save_config(paths.config_file, {"device_name": "Eerste"})
c.save_config(paths.config_file, {"device_name": "Tweede"})
paths.config_file.write_text("{ kapot")
assert c.load_config(paths.config_file)["device_name"] == "Eerste"
def test_save_validates_everything_before_writing(paths):
before = paths.config_file.read_text()
with pytest.raises(c.ConfigError) as exc:
c.save_config(paths.config_file, {"url": "http://x", "refresh_interval": 5, "device_name": "OK"})
assert set(exc.value.errors) == {"url", "refresh_interval"}
assert paths.config_file.read_text() == before
def test_save_rejects_unknown_keys(paths):
with pytest.raises(c.ConfigError, match="Onbekende"):
c.save_config(paths.config_file, {"shell": "rm -rf /"})
def test_save_preserves_unknown_existing_keys_and_writes_backup(paths):
data = json.loads(paths.config_file.read_text())
data["future_option"] = {"x": 1}
paths.config_file.write_text(json.dumps(data))
new = c.save_config(paths.config_file, {"refresh_interval": "300", "screen_always_on": False})
assert new["refresh_interval"] == 300 and new["screen_always_on"] is False
stored = json.loads(paths.config_file.read_text())
assert stored["future_option"] == {"x": 1}
assert json.loads(paths.config_file.with_suffix(".json.bak").read_text())["refresh_interval"] == 0
def test_atomic_write_leaves_no_temp_files_and_sets_mode(paths):
c.save_config(paths.config_file, {"device_name": "X"})
leftovers = [p.name for p in paths.config_dir.iterdir() if p.name.endswith(".tmp")]
assert leftovers == []
assert stat.S_IMODE(os.stat(paths.config_file).st_mode) == 0o640
def test_atomic_write_failure_keeps_old_file(paths, monkeypatch):
before = paths.config_file.read_text()
def boom(*a, **k):
raise OSError("schijf vol")
monkeypatch.setattr(c.os, "replace", boom)
with pytest.raises(OSError):
c.atomic_write_json(paths.config_file, {"a": 1})
assert paths.config_file.read_text() == before
assert not [p for p in paths.config_dir.iterdir() if p.name.endswith(".tmp")]
@pytest.mark.parametrize("key, value, expected", [
("refresh_interval", 0, 0), ("refresh_interval", "60", 60), ("rotation", "270", 270),
("zoom", "1.25", 1.25), ("admin_port", 8443, 8443), ("browser_profile", "ephemeral", "ephemeral"),
])
def test_validate_coerces(key, value, expected):
assert c.validate({key: value})[key] == expected
@pytest.mark.parametrize("key, value", [
("refresh_interval", 10), ("refresh_interval", -1), ("rotation", 45), ("zoom", 9), ("admin_port", 80),
("admin_port", True), ("screen_always_on", "yes"), ("browser_profile", "disk"), ("screen_off_minutes", 0),
])
def test_validate_rejects(key, value):
with pytest.raises(c.ConfigError):
c.validate({key: value})
+85
View File
@@ -0,0 +1,85 @@
import pytest
from voxip_kiosk import services
from voxip_kiosk.cdp import CDPError
from voxip_kiosk.config import save_config
from voxip_kiosk.healthcheck import CDP_FAILURES_BEFORE_RESTART, DISPLAY_RETRY_AFTER, HealthCheck
class World:
def __init__(self):
self.units = {u: {"ActiveState": "active"} for u in (services.X_UNIT, services.BROWSER_UNIT, services.AGENT_UNIT, services.MANAGER_UNIT)}
self.actions = []
self.now = 10_000.0
self.cdp_ok = True
def unit_status(self, unit):
return self.units[unit]
def systemctl(self, *args):
self.actions.append(args)
def version(self):
if not self.cdp_ok:
raise CDPError("timeout")
return {}
@pytest.fixture
def world(paths):
w = World()
hc = HealthCheck(paths, unit_status=w.unit_status, systemctl=w.systemctl, cdp=w, throttled=lambda: None,
disk=lambda p: {"percent": 40}, now=lambda: w.now)
return w, hc
def test_all_healthy_no_actions(world):
w, hc = world
report = hc.run()
assert report["actions"] == [] and w.actions == []
def test_failed_browser_waits_for_cooldown(world):
w, hc = world
w.units[services.BROWSER_UNIT] = {"ActiveState": "failed", "Result": "start-limit-hit"}
hc.run()
assert w.actions == []
w.now += DISPLAY_RETRY_AFTER
hc.run()
assert ("reset-failed", services.BROWSER_UNIT) in w.actions
assert ("start", "--no-block", services.BROWSER_UNIT) in w.actions
def test_failed_manager_restarts_immediately(world):
w, hc = world
w.units[services.MANAGER_UNIT] = {"ActiveState": "failed"}
hc.run()
assert ("start", "--no-block", services.MANAGER_UNIT) in w.actions
def test_browser_restart_disabled_is_respected(world, paths):
w, hc = world
save_config(paths.config_file, {"browser_restart": False})
w.units[services.BROWSER_UNIT] = {"ActiveState": "inactive"}
hc.run()
assert w.actions == []
def test_unresponsive_devtools_restarts_browser(world):
w, hc = world
hc.run() # browser seen active
w.now += 100
w.cdp_ok = False
for _ in range(CDP_FAILURES_BEFORE_RESTART - 1):
hc.run()
assert w.actions == []
hc.run()
assert ("restart", "--no-block", services.BROWSER_UNIT) in w.actions
def test_maintenance_mode_blocks_actions(world, paths):
w, hc = world
(paths.health_run_dir / "maintenance").touch()
w.units[services.AGENT_UNIT] = {"ActiveState": "inactive"}
report = hc.run()
assert report["maintenance"] is True and w.actions == []
+201
View File
@@ -0,0 +1,201 @@
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()
+79
View File
@@ -0,0 +1,79 @@
import pytest
from voxip_kiosk import netconfig as n
from voxip_kiosk.validation import ValidationError
def test_validate_static_ethernet():
req = n.validate_request({"interface": "eth0", "method": "static", "address": "192.168.1.50/24",
"gateway": "192.168.1.1", "dns": "1.1.1.1, 9.9.9.9"})
assert req == {"interface": "eth0", "method": "static", "address": "192.168.1.50/24", "gateway": "192.168.1.1", "dns": ["1.1.1.1", "9.9.9.9"]}
@pytest.mark.parametrize("payload, fragment", [
({"interface": "eth1", "method": "dhcp"}, "interface"),
({"interface": "eth0", "method": "manual"}, "DHCP"),
({"interface": "eth0", "method": "static", "address": "192.168.1.50/24", "gateway": "10.0.0.1", "dns": "1.1.1.1"}, "subnet"),
({"interface": "eth0", "method": "static", "address": "192.168.1.50/24", "gateway": "192.168.1.1", "dns": ""}, "DNS"),
({"interface": "wlan0", "method": "dhcp"}, "Wifi"),
("not a dict", "Ongeldig"),
])
def test_validate_rejects(payload, fragment):
with pytest.raises(ValidationError, match=fragment):
n.validate_request(payload)
def test_keyfile_static_ethernet():
req = n.validate_request({"interface": "eth0", "method": "static", "address": "10.1.2.3/16", "gateway": "10.1.0.1", "dns": ["10.1.0.1"]})
text = n.build_keyfile(req, uuid="11111111-2222-3333-4444-555555555555")
kf = n.read_keyfile(text)
assert kf.get("connection", "id") == "voxip-eth0"
assert kf.get("connection", "type") == "ethernet"
assert kf.get("connection", "autoconnect-priority") == "100"
assert kf.get("ipv4", "method") == "manual"
assert kf.get("ipv4", "address1") == "10.1.2.3/16,10.1.0.1"
assert kf.get("ipv4", "dns") == "10.1.0.1;"
assert kf.get("ipv4", "ignore-auto-dns") == "true"
def test_keyfile_wifi_escapes_values():
req = n.validate_request({"interface": "wlan0", "method": "dhcp", "dns": "",
"wifi": {"ssid": " Café\\Net", "psk": "p\\ss word!", "hidden": True}})
text = n.build_keyfile(req, psk=req["wifi"]["psk"])
kf = n.read_keyfile(text)
assert n.unescape(kf.get("wifi", "ssid")) == " Café\\Net"
assert kf.get("wifi", "ssid").startswith("\\s")
assert n.unescape(kf.get("wifi-security", "psk")) == "p\\ss word!"
assert kf.get("wifi", "hidden") == "true"
assert kf.get("ipv4", "method") == "auto"
assert not kf.has_option("ipv4", "dns")
def test_keyfile_open_wifi_has_no_security_section():
req = n.validate_request({"interface": "wlan0", "method": "dhcp", "wifi": {"ssid": "Gasten", "open": True}})
assert "[wifi-security]" not in n.build_keyfile(req)
def test_keyfile_requires_psk_for_protected_wifi():
req = n.validate_request({"interface": "wlan0", "method": "dhcp", "wifi": {"ssid": "Kantoor"}})
with pytest.raises(ValidationError):
n.build_keyfile(req, psk="")
def test_split_terse_handles_escapes():
assert n.split_terse(r"*:My\:Net:80:WPA2") == ["*", "My:Net", "80", "WPA2"]
assert n.split_terse(r" :back\\slash:40:") == [" ", "back\\slash", "40", ""]
def test_parse_wifi_scan_dedupes_and_sorts():
out = "\n".join([
" :Buren:40:WPA2",
" :Kantoor:55:WPA2",
"*:Kantoor:70:WPA2",
" ::90:WPA2", # hidden network without SSID
" :Gasten:80:",
])
nets = n.parse_wifi_scan(out)
assert [x["ssid"] for x in nets] == ["Kantoor", "Gasten", "Buren"]
assert nets[0]["in_use"] is True and nets[0]["signal"] == 70
assert nets[1]["security"] == "open"
+46
View File
@@ -0,0 +1,46 @@
from voxip_kiosk import services, sysinfo
def test_parse_throttled():
assert sysinfo.parse_throttled("throttled=0x0") == {"raw": "0x0", "now": False, "ever": False, "flags": []}
info = sysinfo.parse_throttled("throttled=0x50005")
assert info["now"] and info["ever"]
assert "Onderspanning" in info["flags"] and "Onderspanning opgetreden" in info["flags"]
assert sysinfo.parse_throttled("garbage") is None
def test_parse_ip_json_keeps_global_addresses():
data = [
{"ifname": "lo", "addr_info": [{"local": "127.0.0.1", "prefixlen": 8, "scope": "host"}]},
{"ifname": "eth0", "addr_info": [{"local": "192.168.1.5", "prefixlen": 24, "scope": "global"},
{"local": "fe80::1", "prefixlen": 64, "scope": "link"}]},
{"ifname": "wlan0", "addr_info": []},
]
assert sysinfo.parse_ip_json(data) == {"eth0": ["192.168.1.5/24"]}
def test_proc_readers(tmp_path):
(tmp_path / "uptime").write_text("3725.5 100.0\n")
(tmp_path / "meminfo").write_text("MemTotal: 1000 kB\nMemFree: 100 kB\nMemAvailable: 250 kB\n")
(tmp_path / "stat").write_text("cpu 100 0 100 800 0 0 0 0 0 0\n")
assert sysinfo.uptime_seconds(str(tmp_path)) == 3725.5
assert sysinfo.memory(str(tmp_path)) == {"total": 1024000, "used": 768000, "percent": 75.0}
assert sysinfo.cpu_percent(str(tmp_path)) is not None
def test_formatting():
assert sysinfo.format_duration(3725) == "1 u 2 min"
assert sysinfo.format_duration(90061) == "1 d 1 u 1 min"
assert sysinfo.format_bytes(1536 * 1024 * 1024) == "1,5 GB"
def test_describe_units():
assert services.describe({"ActiveState": "active"}) == ("ok", "Actief")
assert services.describe({"ActiveState": "failed"})[0] == "error"
assert services.describe({"ActiveState": "inactive", "Result": "exit-code"})[0] == "error"
assert services.describe({"ActiveState": "inactive", "Result": "success"})[0] == "stopped"
assert services.describe({})[0] == "unknown"
def test_parse_show():
assert services.parse_show("ActiveState=active\nNRestarts=3\n") == {"ActiveState": "active", "NRestarts": "3"}
+95
View File
@@ -0,0 +1,95 @@
import pytest
from voxip_kiosk import validation as v
@pytest.mark.parametrize("url", [
"https://schermen-ijsselflat.voxip.nl",
"https://portal.example.nl/display/ab-flat?token=abc123",
"https://10.0.0.5:8443/scherm",
" https://example.org/ ",
])
def test_kiosk_url_accepts_https(url):
assert v.kiosk_url(url) == url.strip()
@pytest.mark.parametrize("url, fragment", [
("http://example.org", "HTTPS"),
("ftp://example.org", "HTTPS"),
("javascript:alert(1)", "HTTPS"),
("file:///etc/passwd", "HTTPS"),
("https://", "hostnaam"),
("https://user:pw@example.org", "wachtwoord"),
("https://exa mple.org", "spaties"),
("https://example.org/\nX", "stuurtekens"),
("https://exa_mple.org", "hostnaam"),
("", "Vul"),
("https://example.org/" + "a" * 2100, "lang"),
])
def test_kiosk_url_rejects(url, fragment):
with pytest.raises(v.ValidationError, match=fragment):
v.kiosk_url(url)
def test_mask_url_hides_tokens_only():
masked = v.mask_url("https://portal.example.nl/display/ab?token=abcdefghijkl1234&theme=dark")
assert "abcdefghijkl" not in masked
assert "token=…1234" in masked
assert "theme=dark" in masked
assert v.mask_url("https://example.org/x") == "https://example.org/x"
assert "secret" not in v.mask_url("https://e.org/?key=secret")
@pytest.mark.parametrize("name, ok", [
("kiosk", True), ("Hal-BG", True), ("a", True), ("-bad", False), ("bad-", False),
("with space", False), ("x" * 64, False), ("localhost", False), ("kiosk.local", False), ("", False),
])
def test_hostname(name, ok):
if ok:
assert v.hostname(name) == name.lower()
else:
with pytest.raises(v.ValidationError):
v.hostname(name)
def test_resolution():
assert v.resolution("auto") == "auto"
assert v.resolution("1920x1080") == "1920x1080"
assert v.resolution("1280X720@60") == "1280x720@60"
assert v.parse_resolution("1920x1080@59.94") == ("1920x1080", "59.94")
assert v.parse_resolution("auto") is None
for bad in ("1920", "99999x1", "1920x1080@", "big"):
with pytest.raises(v.ValidationError):
v.resolution(bad)
def test_ip_helpers():
assert v.ipv4_interface("192.168.1.50/24") == "192.168.1.50/24"
with pytest.raises(v.ValidationError):
v.ipv4_interface("192.168.1.50")
with pytest.raises(v.ValidationError, match="netwerk- of broadcast"):
v.ipv4_interface("192.168.1.0/24")
with pytest.raises(v.ValidationError):
v.ipv4_interface("192.168.1.300/24")
assert v.dns_servers("1.1.1.1, 9.9.9.9;2606:4700:4700::1111") == ["1.1.1.1", "9.9.9.9", "2606:4700:4700::1111"]
assert v.dns_servers("") == []
with pytest.raises(v.ValidationError):
v.dns_servers("1.1.1.1 2.2.2.2 3.3.3.3 4.4.4.4")
with pytest.raises(v.ValidationError):
v.dns_servers("dns.google")
def test_wifi_values():
assert v.ssid("Kantoor 2.4GHz") == "Kantoor 2.4GHz"
with pytest.raises(v.ValidationError):
v.ssid("x" * 33)
with pytest.raises(v.ValidationError):
v.ssid("bad\nssid")
assert v.wifi_psk("geheim123") == "geheim123"
assert v.wifi_psk("a" * 64) == "a" * 64
for bad in ("kort", "x" * 64, "geheimé123"):
with pytest.raises(v.ValidationError):
v.wifi_psk(bad)
assert v.country_code("nl") == "NL"
with pytest.raises(v.ValidationError):
v.country_code("NLD")
+300
View File
@@ -0,0 +1,300 @@
import json
import pytest
from voxip_kiosk.config import load_config
from voxip_kiosk.web import auth
from .conftest import PASSWORD, csrf_from, token
# --- initial setup -------------------------------------------------------------
def test_everything_redirects_to_setup_until_configured(client, paths):
for path in ("/", "/kiosk", "/system", "/login", "/network", "/logs"):
resp = client.get(path)
assert resp.status_code == 302 and resp.headers["Location"].endswith("/setup"), path
assert paths.setup_code_file.exists()
assert oct(paths.setup_code_file.stat().st_mode & 0o777) == "0o640"
def test_setup_requires_code_and_strong_password(client, paths):
page = client.get("/setup")
tok = csrf_from(page)
code = paths.setup_code_file.read_text().strip()
resp = client.post("/setup", data={"csrf_token": tok, "setup_code": "AAAA-BBBB", "password": PASSWORD, "password_confirm": PASSWORD})
assert "setupcode klopt niet" in resp.get_data(as_text=True)
assert not paths.admin_file.exists()
resp = client.post("/setup", data={"csrf_token": tok, "setup_code": code, "password": "admin12345", "password_confirm": "admin12345"})
assert "te eenvoudig" in resp.get_data(as_text=True)
resp = client.post("/setup", data={"csrf_token": tok, "setup_code": code.lower().replace("-", ""), "password": PASSWORD, "password_confirm": PASSWORD})
assert resp.status_code == 302
stored = json.loads(paths.admin_file.read_text())
assert stored["password_hash"].startswith("$argon2id$")
assert PASSWORD not in paths.admin_file.read_text()
assert oct(paths.admin_file.stat().st_mode & 0o777) == "0o600"
assert not paths.setup_code_file.exists()
assert client.get("/").status_code == 200
assert client.get("/setup").status_code == 302
def test_setup_without_csrf_is_rejected(client, paths):
client.get("/setup")
code = paths.setup_code_file.read_text().strip()
resp = client.post("/setup", data={"setup_code": code, "password": PASSWORD, "password_confirm": PASSWORD})
assert resp.status_code == 303
assert not paths.admin_file.exists()
# --- login & session -------------------------------------------------------------
def test_login_logout(app, client):
app.extensions["voxip"].credentials.set_password(PASSWORD)
assert client.get("/").status_code == 302
tok = csrf_from(client.get("/login"))
assert "Onjuist" in client.post("/login", data={"csrf_token": tok, "password": "fout-wachtwoord"}).get_data(as_text=True)
assert client.post("/login", data={"csrf_token": tok, "password": PASSWORD}).status_code == 302
assert client.get("/").status_code == 200
client.post("/logout", data={"csrf_token": token(client)})
assert client.get("/").status_code == 302
def test_session_cookie_flags(app, client):
app.extensions["voxip"].credentials.set_password(PASSWORD)
tok = csrf_from(client.get("/login"))
resp = client.post("/login", data={"csrf_token": tok, "password": PASSWORD})
cookie = resp.headers["Set-Cookie"]
assert "HttpOnly" in cookie and "SameSite=Strict" in cookie
def test_login_throttling(app, client):
app.extensions["voxip"].credentials.set_password(PASSWORD)
tok = csrf_from(client.get("/login"))
for _ in range(5):
client.post("/login", data={"csrf_token": tok, "password": "verkeerd-wachtwoord"})
resp = client.post("/login", data={"csrf_token": tok, "password": PASSWORD})
assert "Te veel mislukte pogingen" in resp.get_data(as_text=True)
assert client.get("/").status_code == 302
def test_throttle_backoff_and_global_brake():
now = [0.0]
t = auth.LoginThrottle(clock=lambda: now[0], global_limit=8)
for _ in range(4):
t.failure("1.1.1.1")
assert t.retry_after("1.1.1.1") == 0
t.failure("1.1.1.1")
assert t.retry_after("1.1.1.1") == 30
t.failure("1.1.1.1")
assert t.retry_after("1.1.1.1") == 60
now[0] += 61
assert t.retry_after("1.1.1.1") == 0
t.success("1.1.1.1")
for i in range(8):
t.failure(f"10.0.0.{i}")
assert t.retry_after("192.168.0.99") > 0
def test_password_change_invalidates_other_sessions(app, logged_in):
other = app.test_client()
tok = csrf_from(other.get("/login"))
other.post("/login", data={"csrf_token": tok, "password": PASSWORD})
assert other.get("/").status_code == 200
new = "Nieuw-En-Lang-Wachtwoord-7"
resp = logged_in.post("/account/password", data={"csrf_token": token(logged_in), "current_password": PASSWORD,
"password": new, "password_confirm": new})
assert resp.status_code == 302
assert logged_in.get("/").status_code == 200
assert other.get("/").status_code == 302
def test_idle_session_expires(app, logged_in, monkeypatch):
from voxip_kiosk.web import views
real = views.time.time
monkeypatch.setattr(views.time, "time", lambda: real() + views.IDLE_TIMEOUT + 5)
assert logged_in.get("/").status_code == 302
def test_password_policy():
assert auth.password_problem("kort") is not None
assert auth.password_problem("aaaaaaaaaaaa") is not None
assert auth.password_problem("Password123") is not None
assert auth.password_problem("lang-genoeg-en-uniek", "anders") == "De wachtwoorden komen niet overeen."
assert auth.password_problem("lang-genoeg-en-uniek", "lang-genoeg-en-uniek") is None
# --- network access & headers ------------------------------------------------------
@pytest.mark.parametrize("addr, allowed", [
("192.168.1.10", True), ("10.1.2.3", True), ("172.16.0.1", True), ("127.0.0.1", True), ("fe80::1", True),
("fd00::5", True), ("100.101.102.103", True), ("::ffff:192.168.1.10", True),
("8.8.8.8", False), ("2001:4860::8888", False), ("::ffff:8.8.8.8", False),
])
def test_public_clients_blocked(client, addr, allowed):
resp = client.get("/login", environ_base={"REMOTE_ADDR": addr})
assert (resp.status_code != 403) == allowed, addr
def test_same_subnet_global_ipv6_allowed():
from voxip_kiosk.web.security import client_allowed
assert client_allowed("2a02:a210:1:2::99", False, ["2a02:a210:1:2::5/64"])
assert not client_allowed("2a02:a210:9:9::99", False, ["2a02:a210:1:2::5/64"])
assert client_allowed("8.8.8.8", True)
def test_security_headers(client):
resp = client.get("/setup")
assert "default-src 'self'" in resp.headers["Content-Security-Policy"]
assert resp.headers["X-Frame-Options"] == "DENY"
assert resp.headers["Cache-Control"] == "no-store"
def test_api_requires_login(app, client):
app.extensions["voxip"].credentials.set_password(PASSWORD)
resp = client.get("/api/status")
assert resp.status_code == 401 and resp.json["error"]
def test_post_without_csrf_rejected(logged_in, fake_helper):
resp = logged_in.post("/system/reboot")
assert resp.status_code == 303
assert fake_helper.calls == []
resp = logged_in.post("/system/reboot", data={"csrf_token": token(logged_in)}, headers={"Origin": "http://evil.example"})
assert fake_helper.calls == []
# --- pages ---------------------------------------------------------------------------
@pytest.mark.parametrize("path", ["/", "/kiosk", "/system", "/network", "/logs", "/logs?source=network&lines=50", "/account/password"])
def test_pages_render(logged_in, path):
resp = logged_in.get(path)
assert resp.status_code == 200, path
body = resp.get_data(as_text=True)
assert "VoxIP Kiosk Manager" in body and "9.9.9-test" in body
def test_dashboard_masks_token(logged_in, paths):
from voxip_kiosk.config import save_config
save_config(paths.config_file, {"url": "https://portal.example.nl/display/hal?token=supergeheimtoken99"})
body = logged_in.get("/").get_data(as_text=True)
assert "supergeheimtoken" not in body and "token=…en99" in body
assert "supergeheimtoken" not in json.dumps(logged_in.get("/api/status").json)
def test_api_status_shape(logged_in):
data = logged_in.get("/api/status").json
for key in ("browser", "url", "uptime", "cpu", "temperature", "memory", "disk", "eth0", "wlan0", "network", "version"):
assert key in data
assert data["eth0"] == "192.168.10.20/24"
assert data["power"]["status"] == "warning"
# --- actions -------------------------------------------------------------------------
def test_kiosk_settings_valid_and_invalid(logged_in, paths):
tok = token(logged_in)
form = {"csrf_token": tok, "url": "https://nieuw.example.nl/scherm", "device_name": "Hal", "refresh_interval": "300",
"browser_restart": "1", "browser_profile": "ephemeral"}
logged_in.post("/kiosk/settings", data=form)
conf = load_config(paths.config_file)
assert conf["url"] == "https://nieuw.example.nl/scherm" and conf["refresh_interval"] == 300
assert conf["browser_profile"] == "ephemeral"
resp = logged_in.post("/kiosk/settings", data=dict(form, url="http://onveilig.example"), follow_redirects=True)
assert "Alleen HTTPS" in resp.get_data(as_text=True)
assert load_config(paths.config_file)["url"] == "https://nieuw.example.nl/scherm"
logged_in.post("/kiosk/settings", data={k: v for k, v in form.items() if k != "browser_restart"})
assert load_config(paths.config_file)["browser_restart"] is False
def test_reload_uses_devtools_not_restart(logged_in, fake_cdp, fake_helper):
logged_in.post("/kiosk/reload", data={"csrf_token": token(logged_in)})
assert fake_cdp.calls == [("reload", ())]
assert fake_helper.calls == []
def test_reload_failure_is_reported(logged_in, fake_cdp):
fake_cdp.fail = "DevTools niet bereikbaar"
resp = logged_in.post("/kiosk/reload", data={"csrf_token": token(logged_in)}, follow_redirects=True)
assert "Herladen mislukt" in resp.get_data(as_text=True)
@pytest.mark.parametrize("path, verb", [
("/kiosk/restart", "restart-browser"), ("/system/reboot", "reboot"), ("/system/poweroff", "poweroff"),
("/network/confirm", "network-confirm"), ("/network/rollback", "network-rollback"),
])
def test_helper_actions(logged_in, fake_helper, path, verb):
logged_in.post(path, data={"csrf_token": token(logged_in)})
assert fake_helper.calls[-1][0] == verb
def test_restart_and_reboot_are_separate(logged_in, fake_helper):
logged_in.post("/kiosk/restart", data={"csrf_token": token(logged_in)})
assert [c[0] for c in fake_helper.calls] == ["restart-browser"]
def test_hostname_passed_as_single_argument(logged_in, fake_helper):
logged_in.post("/system/hostname", data={"csrf_token": token(logged_in), "hostname": "Hal-BG"})
assert fake_helper.calls[-1] == ("set-hostname", ("hal-bg",), None)
def test_display_settings_saved_and_applied(logged_in, fake_helper, paths):
logged_in.post("/system/display", data={"csrf_token": token(logged_in), "rotation": "90", "resolution": "1280x720",
"zoom": "1,25", "screen_off_minutes": "10", "apply_now": "1"})
conf = load_config(paths.config_file)
assert conf["rotation"] == 90 and conf["resolution"] == "1280x720" and conf["zoom"] == 1.25
assert conf["screen_always_on"] is False
assert fake_helper.calls[-1][0] == "restart-browser"
def test_network_apply_validates_before_helper(logged_in, fake_helper):
tok = token(logged_in)
resp = logged_in.post("/network/apply", data={"csrf_token": tok, "interface": "eth0", "method": "static",
"address": "192.168.1.50", "gateway": "", "dns": ""}, follow_redirects=True)
assert "prefixlengte" in resp.get_data(as_text=True)
assert fake_helper.calls == []
logged_in.post("/network/apply", data={"csrf_token": tok, "interface": "wlan0", "method": "dhcp", "ssid": "Kantoor",
"psk": "geheim-wachtwoord", "dns": ""})
verb, args, payload = fake_helper.calls[-1]
assert verb == "network-apply" and args == ()
assert payload["wifi"] == {"ssid": "Kantoor", "psk": "geheim-wachtwoord", "hidden": False, "open": False}
def test_wifi_scan_json(logged_in, fake_helper):
fake_helper.responses["wifi-scan"] = {"networks": [{"ssid": "Kantoor", "signal": 70, "security": "WPA2", "in_use": True}]}
resp = logged_in.post("/api/network/scan", headers={"X-CSRF-Token": token(logged_in), "Accept": "application/json"})
assert resp.status_code == 200 and resp.json["networks"][0]["ssid"] == "Kantoor"
def test_helper_error_is_shown(logged_in, fake_helper):
fake_helper.fail = "Opdracht niet toegestaan"
resp = logged_in.post("/system/reboot", data={"csrf_token": token(logged_in)}, follow_redirects=True)
assert "Opdracht niet toegestaan" in resp.get_data(as_text=True)
def test_screenshot(logged_in, fake_cdp):
resp = logged_in.get("/kiosk/screenshot.jpg")
assert resp.status_code == 200 and resp.mimetype == "image/jpeg"
fake_cdp.fail = "weg"
assert logged_in.get("/kiosk/screenshot.jpg").status_code == 503
def test_audit_log_written(logged_in, paths):
logged_in.post("/kiosk/reload", data={"csrf_token": token(logged_in)})
events = [json.loads(line)["event"] for line in paths.audit_log.read_text().splitlines()]
assert "login" in events and "page_reload" in events
def test_admin_port_change_restarts_manager(logged_in, fake_helper, paths):
resp = logged_in.post("/system/admin", data={"csrf_token": token(logged_in), "admin_port": "8443"})
assert resp.status_code == 200 and ":8443/" in resp.get_data(as_text=True)
assert fake_helper.calls[-1][0] == "restart-manager"
assert load_config(paths.config_file)["admin_port"] == 8443