VoxIP Kiosk OS 1.0.0: Raspberry Pi kiosk with web management
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:
commit
a33c4822bf
96 files changed
+7535
No files matched your search
Whitespace-only changes.
@@ -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("/"))
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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})
|
||||
@@ -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 == []
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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"}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
Reference in new issue
Block a user