TESM-Lizenzserver 2.0.0 -- Neubau
Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm-license (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Die verwaltete Anwendung liegt in ihrem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger: * Keine doppelte licensing.py -- ein Paket, das beide Anwendungen installieren, statt zweier Dateien, die byte-identisch bleiben sollen. * Der Webprozess laeuft unprivilegiert; alles, was Root braucht, geht ueber einen einzigen Helfer mit Positivlisten fuer jedes Argument. * CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen. * Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet. * Keine Lizenz ohne master_endpoint: eine Ausstellung ohne Endpunkt wird abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann. * Offline-Aktivierung in beide Richtungen; die Lizenz bleibt als "Aktivierung offen" markiert, bis sie zurueckkommt. * Getrennte Signaturkontexte je Nachrichtenart, Nonce gegen Wiedereinspielung, seq gegen das Zurueckrollen auf eine aeltere Lizenz. * Kein Hostname im Maschinen-Fingerabdruck. * Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,521 @@
|
||||
"""Die nginx-Vorlage und die Trennung mehrerer Instanzen auf einem Host.
|
||||
|
||||
Zwei Fehlerklassen werden hier festgenagelt:
|
||||
|
||||
1. Der alias-Fehler des Vorgaengers -- statische Dateien zeigten auf ein
|
||||
Verzeichnis, das es nicht gab, und die Oberflaeche kam jahrelang ohne
|
||||
Design. Die erzeugte Datei muss die richtigen Pfade enthalten.
|
||||
2. Zwei Installationen derselben Anwendung auf einem Host. Solange alles
|
||||
ueber den ``app_key`` benannt wurde, hat eine Testinstanz beim Speichern
|
||||
der Webserver-Einstellungen die Site-Datei der Produktion ueberschrieben
|
||||
-- und beide teilten sich dasselbe Sitzungscookie, weil Cookies keine
|
||||
Ports unterscheiden.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from tesm_core.system import webserver
|
||||
from tesm_core.sysops import HELPER_VERBS, Result, SysOps
|
||||
|
||||
|
||||
def make_config(**overrides) -> webserver.WebConfig:
|
||||
base = {
|
||||
"app_key": "tesm",
|
||||
"upstream": "127.0.0.1:5000",
|
||||
"static_root": "/srv/tesm/static",
|
||||
}
|
||||
return webserver.WebConfig(**{**base, **overrides})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Nur HTTP
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_http_only_has_no_tls_block():
|
||||
text = webserver.render(make_config())
|
||||
assert "listen 80 default_server;" in text
|
||||
assert "ssl_certificate" not in text
|
||||
assert "return 308" not in text
|
||||
|
||||
|
||||
def test_static_aliases_point_into_the_installation():
|
||||
text = webserver.render(make_config())
|
||||
assert "alias /srv/tesm/static/;" in text
|
||||
assert "alias /srv/tesm/static-core/;" in text
|
||||
|
||||
|
||||
def test_proxy_passes_the_host_including_port():
|
||||
"""``$host`` verwirft den Port -- damit scheitert die Origin-Pruefung.
|
||||
|
||||
Genau daran ist die Anmeldung hinter dem Reverse Proxy auf einem
|
||||
abweichenden Port real gescheitert.
|
||||
"""
|
||||
text = webserver.render(make_config())
|
||||
assert "proxy_set_header Host $http_host;" in text
|
||||
assert "proxy_set_header Host $host;" not in text
|
||||
assert "proxy_set_header X-Forwarded-Port $server_port;" in text
|
||||
|
||||
|
||||
def test_acme_path_is_always_reachable():
|
||||
text = webserver.render(
|
||||
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
|
||||
)
|
||||
# Auch bei aktiver Umleitung -- sonst kann Let's Encrypt nie erneuern.
|
||||
assert "/.well-known/acme-challenge/" in text
|
||||
challenge = text.index("acme-challenge")
|
||||
redirect = text.index("return 308")
|
||||
assert challenge < redirect
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# HTTPS
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_https_listens_on_443_and_redirects_80():
|
||||
text = webserver.render(
|
||||
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
|
||||
)
|
||||
assert "listen 443 ssl default_server http2;" in text
|
||||
assert "listen [::]:443 ssl default_server http2;" in text
|
||||
assert "return 308 https://$host$request_uri;" in text
|
||||
assert "ssl_protocols TLSv1.2 TLSv1.3;" in text
|
||||
|
||||
|
||||
def test_hsts_only_when_asked():
|
||||
without = webserver.render(
|
||||
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
|
||||
)
|
||||
assert "Strict-Transport-Security" not in without
|
||||
|
||||
with_hsts = webserver.render(
|
||||
make_config(
|
||||
https_enabled=True, hsts_enabled=True, cert_path="/c.pem", key_path="/k.pem"
|
||||
)
|
||||
)
|
||||
assert "Strict-Transport-Security" in with_hsts
|
||||
# HSTS ohne TLS waere ein Selbstschuss: der Browser merkt sich, dass er die
|
||||
# Seite nur noch ueber HTTPS aufrufen darf.
|
||||
assert "ssl_certificate" in with_hsts
|
||||
|
||||
|
||||
def test_hsts_is_ignored_without_https():
|
||||
text = webserver.render(make_config(hsts_enabled=True))
|
||||
assert "Strict-Transport-Security" not in text
|
||||
|
||||
|
||||
def test_redirect_can_be_switched_off():
|
||||
text = webserver.render(
|
||||
make_config(
|
||||
https_enabled=True, redirect_http=False, cert_path="/c.pem", key_path="/k.pem"
|
||||
)
|
||||
)
|
||||
assert "return 308" not in text
|
||||
# Dann muss Port 80 die Anwendung weiter bedienen.
|
||||
assert text.count("proxy_pass http://127.0.0.1:5000;") >= 2
|
||||
|
||||
|
||||
def test_deviating_ports_are_rendered_and_redirect_keeps_the_port():
|
||||
text = webserver.render(
|
||||
make_config(
|
||||
https_enabled=True,
|
||||
cert_path="/c.pem",
|
||||
key_path="/k.pem",
|
||||
http_port=8080,
|
||||
https_port=8443,
|
||||
)
|
||||
)
|
||||
assert "listen 8080 default_server;" in text
|
||||
assert "listen 8443 ssl default_server http2;" in text
|
||||
# Ohne den Port im Ziel liefe die Umleitung ins Leere.
|
||||
assert "return 308 https://$host:8443$request_uri;" in text
|
||||
|
||||
|
||||
def test_standard_port_leaves_no_suffix():
|
||||
assert make_config(https_port=443).https_suffix == ""
|
||||
assert make_config(https_port=8443).https_suffix == ":8443"
|
||||
|
||||
|
||||
def test_websocket_location_only_when_configured():
|
||||
assert "proxy_set_header Upgrade" not in webserver.render(make_config())
|
||||
text = webserver.render(make_config(websocket_path="/ws/"))
|
||||
assert "location /ws/ {" in text
|
||||
assert "proxy_read_timeout 3600s;" in text
|
||||
|
||||
|
||||
def _location_blocks(text: str) -> list[list[str]]:
|
||||
"""Zerlegt die erzeugte Datei in ihre ``location``-Bloecke."""
|
||||
blocks: list[list[str]] = []
|
||||
current: list[str] | None = None
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("location ") and stripped.endswith("{"):
|
||||
current = []
|
||||
elif stripped == "}" and current is not None:
|
||||
blocks.append(current)
|
||||
current = None
|
||||
elif current is not None and stripped and not stripped.startswith("#"):
|
||||
current.append(stripped)
|
||||
return blocks
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
[
|
||||
make_config(websocket_path="/ws/"),
|
||||
make_config(
|
||||
websocket_path="/ws/", https_enabled=True, cert_path="/c.pem", key_path="/k.pem"
|
||||
),
|
||||
make_config(https_enabled=True, hsts_enabled=True, cert_path="/c.pem", key_path="/k.pem"),
|
||||
],
|
||||
)
|
||||
def test_no_directive_appears_twice_in_one_block(config: webserver.WebConfig):
|
||||
"""nginx weist eine doppelte Direktive im selben Block ab.
|
||||
|
||||
Auf POETEST real passiert: der Websocket-Block erbte ``proxy_read_timeout
|
||||
120s`` aus dem gemeinsamen Teil und setzte danach ``3600s``. nginx meldete
|
||||
"directive is duplicate" und liess sich nicht mehr starten -- nicht nur
|
||||
fuer diese Site, sondern fuer alle.
|
||||
"""
|
||||
for block in _location_blocks(webserver.render(config)):
|
||||
directives = [line.split()[0] for line in block if line.endswith(";")]
|
||||
repeated = {
|
||||
name
|
||||
for name in directives
|
||||
# Wiederholung ist nur bei den Direktiven zulaessig, die nginx als
|
||||
# Liste versteht.
|
||||
if directives.count(name) > 1 and name not in {"proxy_set_header", "add_header"}
|
||||
}
|
||||
assert not repeated, f"Mehrfach im selben Block: {sorted(repeated)}"
|
||||
|
||||
|
||||
def test_websocket_block_keeps_the_long_timeout():
|
||||
"""Die lange Wartezeit ist der Zweck des Blocks -- sie darf nicht verlorengehen."""
|
||||
blocks = {
|
||||
block[0]: block
|
||||
for block in _location_blocks(webserver.render(make_config(websocket_path="/ws/")))
|
||||
}
|
||||
websocket = next(b for b in blocks.values() if "proxy_set_header Upgrade $http_upgrade;" in b)
|
||||
assert "proxy_read_timeout 3600s;" in websocket
|
||||
assert "proxy_read_timeout 120s;" not in websocket
|
||||
|
||||
|
||||
def test_http2_uses_the_old_spelling_by_default():
|
||||
"""``http2 on;`` kennt erst nginx 1.25.1.
|
||||
|
||||
Auf POETEST (nginx 1.24) fuehrte die neue Direktive zu "unknown directive"
|
||||
-- und damit zu einem Webserver, der sich nicht mehr starten liess. Die
|
||||
alte Schreibweise laeuft ueberall.
|
||||
"""
|
||||
text = webserver.render(
|
||||
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
|
||||
)
|
||||
assert "listen 443 ssl default_server http2;" in text
|
||||
assert "http2 on;" not in text
|
||||
|
||||
|
||||
def test_http2_directive_on_request():
|
||||
text = webserver.render(
|
||||
make_config(
|
||||
https_enabled=True, cert_path="/c.pem", key_path="/k.pem", http2_directive=True
|
||||
)
|
||||
)
|
||||
assert " http2 on;" in text
|
||||
assert "listen 443 ssl default_server;" in text
|
||||
assert "ssl http2;" not in text
|
||||
|
||||
|
||||
def test_stapling_only_with_a_real_certificate():
|
||||
"""Ein selbstsigniertes Zertifikat hat keinen OCSP-Responder."""
|
||||
self_signed = webserver.render(
|
||||
make_config(https_enabled=True, cert_path="/c.pem", key_path="/k.pem")
|
||||
)
|
||||
assert "ssl_stapling" not in self_signed
|
||||
|
||||
public = webserver.render(
|
||||
make_config(
|
||||
https_enabled=True,
|
||||
domain="tesm.example.org",
|
||||
cert_path="/c.pem",
|
||||
key_path="/k.pem",
|
||||
)
|
||||
)
|
||||
assert "ssl_stapling on;" in public
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Domain
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value", ["tesm.example.org", "a.b.c.de", "TESM.Example.ORG", "tesm.example.org."]
|
||||
)
|
||||
def test_valid_domains(value: str):
|
||||
assert webserver.validate_domain(value) == value.lower().rstrip(".")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value", ["kein-punkt", "-start.de", "ende-.de", "raum .de", "a..b", "http://x.de"]
|
||||
)
|
||||
def test_invalid_domains(value: str):
|
||||
with pytest.raises(ValueError):
|
||||
webserver.validate_domain(value)
|
||||
|
||||
|
||||
def test_empty_domain_becomes_catch_all():
|
||||
assert make_config().server_name == "_"
|
||||
assert make_config(domain="tesm.example.org").server_name == "tesm.example.org"
|
||||
|
||||
|
||||
def test_only_the_nameless_site_claims_default_server():
|
||||
"""Sonst entscheidet der Dateiname in sites-enabled, wer die Vorgabe wird.
|
||||
|
||||
``server_name _;`` ist bei nginx kein Sonderfall -- ohne
|
||||
``default_server`` gewinnt schlicht die zuerst geladene Datei. Bei zwei
|
||||
Anwendungen auf einem Host waere das reiner Zufall.
|
||||
"""
|
||||
nameless = webserver.render(make_config())
|
||||
assert "listen 80 default_server;" in nameless
|
||||
|
||||
named = webserver.render(make_config(domain="lizenz.example.org"))
|
||||
assert "default_server" not in named
|
||||
assert "server_name lizenz.example.org;" in named
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Selbstsigniertes Zertifikat
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class FakeSysOps:
|
||||
"""Merkt sich den Aufruf, statt den Helfer wirklich zu starten."""
|
||||
|
||||
def __init__(self, result: Result) -> None:
|
||||
self.result = result
|
||||
self.calls: list[tuple[str, ...]] = []
|
||||
self.available = True
|
||||
self.site_key = "tesm-opus"
|
||||
|
||||
def issue_self_signed(self, common_name: str, *alt_names: str) -> Result:
|
||||
self.calls.append((common_name, *alt_names))
|
||||
return self.result
|
||||
|
||||
|
||||
def test_self_signed_returns_both_paths():
|
||||
sysops = FakeSysOps(Result(True, "/etc/tesm/certs/tesm/fullchain.pem\n/etc/tesm/certs/tesm/privkey.pem\n"))
|
||||
result = webserver.create_self_signed(
|
||||
sysops, common_name="poetest", alt_names=["poetest.local", "192.168.82.202"]
|
||||
)
|
||||
assert result["ok"]
|
||||
assert result["cert_path"].endswith("fullchain.pem")
|
||||
assert result["key_path"].endswith("privkey.pem")
|
||||
assert sysops.calls == [("poetest", "poetest.local", "192.168.82.202")]
|
||||
|
||||
|
||||
def test_self_signed_rejects_a_hostile_name():
|
||||
"""Der Name landet in einem openssl-Argument -- er wird hier geprueft."""
|
||||
sysops = FakeSysOps(Result(True, "a\nb\n"))
|
||||
for hostile in ("a b", "a;rm -rf /", "$(whoami)", "../../etc", ""):
|
||||
result = webserver.create_self_signed(sysops, common_name=hostile)
|
||||
assert not result["ok"], hostile
|
||||
assert sysops.calls == []
|
||||
|
||||
|
||||
def test_self_signed_drops_unusable_alternative_names():
|
||||
sysops = FakeSysOps(Result(True, "a\nb\n"))
|
||||
webserver.create_self_signed(
|
||||
sysops, common_name="poetest", alt_names=["poetest", "", "bad name", "ok.local"]
|
||||
)
|
||||
assert sysops.calls == [("poetest", "ok.local")]
|
||||
|
||||
|
||||
def test_self_signed_reports_a_failing_helper():
|
||||
sysops = FakeSysOps(Result(False, "", "openssl fehlt", 2))
|
||||
result = webserver.create_self_signed(sysops, common_name="poetest")
|
||||
assert not result["ok"]
|
||||
assert "openssl fehlt" in result["detail"]
|
||||
|
||||
|
||||
def test_self_signed_rejects_an_unexpected_answer():
|
||||
sysops = FakeSysOps(Result(True, "nur eine Zeile\n"))
|
||||
result = webserver.create_self_signed(sysops, common_name="poetest")
|
||||
assert not result["ok"]
|
||||
|
||||
|
||||
def test_certificate_info_survives_an_unreadable_file(tmp_path, monkeypatch):
|
||||
"""Ein unlesbares Zertifikat darf die Einstellungsseite nicht abwerfen.
|
||||
|
||||
Auf POETEST real passiert: das Zertifikatsverzeichnis war 0750 root:root,
|
||||
der Dienstbenutzer bekam beim blossen ``stat`` einen PermissionError -- und
|
||||
die Seite "Webserver und TLS" antwortete mit HTTP 500.
|
||||
"""
|
||||
target = tmp_path / "fullchain.pem"
|
||||
target.write_text("egal", encoding="utf-8")
|
||||
|
||||
def deny(*_args, **_kwargs):
|
||||
raise PermissionError(13, "Permission denied")
|
||||
|
||||
monkeypatch.setattr("pathlib.Path.is_file", deny)
|
||||
info = webserver.certificate_info(str(target))
|
||||
assert info["present"] is True
|
||||
assert "nicht lesen" in info["error"]
|
||||
|
||||
|
||||
def test_certificate_info_reports_a_missing_file():
|
||||
assert webserver.certificate_info("/gibt/es/nicht.pem") == {"present": False}
|
||||
|
||||
|
||||
def test_certificate_info_reports_garbage_without_raising(tmp_path):
|
||||
target = tmp_path / "fullchain.pem"
|
||||
target.write_text("kein Zertifikat", encoding="utf-8")
|
||||
info = webserver.certificate_info(str(target))
|
||||
assert info["present"] is True
|
||||
assert info["error"]
|
||||
|
||||
|
||||
def test_local_names_are_usable_in_a_certificate():
|
||||
names = webserver.local_names()
|
||||
assert names, "Es sollte mindestens der Hostname gefunden werden."
|
||||
for name in names:
|
||||
assert re.fullmatch(r"[A-Za-z0-9]([A-Za-z0-9.-]{0,251}[A-Za-z0-9])?", name), name
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Instanztrennung
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_apply_config_is_one_call_with_rollback():
|
||||
"""Getrennte Aufrufe liessen eine abgelehnte Konfiguration aktiviert liegen.
|
||||
|
||||
``nginx-write`` verlinkt sofort nach ``sites-enabled``. Schlug danach
|
||||
``nginx -t`` fehl, blieb die kaputte Datei aktiv -- der laufende nginx
|
||||
merkte nichts, aber der naechste Reload durch irgendwen legte den
|
||||
Webserver lahm. Die Uebernahme muss deshalb *eine* privilegierte Operation
|
||||
sein, die sich selbst zuruecknimmt.
|
||||
"""
|
||||
calls: list[str] = []
|
||||
|
||||
class Recording(SysOps):
|
||||
def run(self, verb: str, *args: str, stdin: str = "", timeout: int | None = None) -> Result:
|
||||
calls.append(verb)
|
||||
return Result(True, "uebernommen")
|
||||
|
||||
result = webserver.apply_config(Recording(app_key="tesm"), "server {}")
|
||||
assert result["ok"]
|
||||
assert calls == ["nginx-apply"]
|
||||
assert "nginx-write" not in calls
|
||||
|
||||
|
||||
def test_apply_config_reports_a_rejection_without_claiming_success():
|
||||
class Failing(SysOps):
|
||||
def run(self, verb: str, *args: str, stdin: str = "", timeout: int | None = None) -> Result:
|
||||
return Result(False, "", "unknown directive \"http2\"", 2)
|
||||
|
||||
result = webserver.apply_config(Failing(app_key="tesm"), "server {}")
|
||||
assert not result["ok"]
|
||||
assert "alte Stand" in result["message"]
|
||||
assert "http2" in result["detail"]
|
||||
|
||||
|
||||
def test_sysops_writes_system_files_under_the_site_key():
|
||||
"""Sonst ueberschreibt eine Testinstanz die Systemdateien der Produktion."""
|
||||
calls: list[tuple[str, ...]] = []
|
||||
|
||||
class Recording(SysOps):
|
||||
def run(self, verb: str, *args: str, stdin: str = "", timeout: int | None = None) -> Result:
|
||||
calls.append((verb, *args))
|
||||
return Result(True)
|
||||
|
||||
sysops = Recording(app_key="tesm", site_key="tesm-opus")
|
||||
sysops.write_nginx("x")
|
||||
sysops.write_netplan("x")
|
||||
sysops.write_logrotate("x")
|
||||
sysops.issue_self_signed("poetest")
|
||||
|
||||
assert calls == [
|
||||
("nginx-write", "tesm-opus"),
|
||||
("netplan-write", "tesm-opus"),
|
||||
("logrotate-write", "tesm-opus"),
|
||||
("selfsigned-issue", "tesm-opus", "poetest"),
|
||||
]
|
||||
|
||||
|
||||
def test_upstream_follows_the_configured_bind_address(monkeypatch):
|
||||
"""Die nginx-Site muss auf *diese* Instanz zeigen, nicht auf Port 5000.
|
||||
|
||||
Mit fest verdrahteter 5000 erzeugte eine Instanz auf Port 5100 eine
|
||||
Konfiguration, die Anfragen an die *andere* Installation weiterreichte --
|
||||
auf POETEST reproduziert: HTTPS stand, lieferte aber die alte Anwendung.
|
||||
"""
|
||||
from tesm_core.config import load_core_config
|
||||
|
||||
monkeypatch.setenv("TESM_BIND", "127.0.0.1:5100")
|
||||
config = load_core_config(app_key="tesm", app_name="TESM", env_prefix="TESM_")
|
||||
assert config.bind == "127.0.0.1:5100"
|
||||
|
||||
text = webserver.render(make_config(upstream=config.bind))
|
||||
assert "proxy_pass http://127.0.0.1:5100;" in text
|
||||
assert "127.0.0.1:5000" not in text
|
||||
|
||||
|
||||
def test_bind_address_is_validated(monkeypatch):
|
||||
from tesm_core.config import load_core_config
|
||||
|
||||
monkeypatch.setenv("TESM_BIND", "127.0.0.1:5100; rm -rf /")
|
||||
with pytest.raises(ValueError):
|
||||
load_core_config(app_key="tesm", app_name="TESM", env_prefix="TESM_")
|
||||
|
||||
|
||||
def test_site_key_is_validated(monkeypatch):
|
||||
from tesm_core.config import load_core_config
|
||||
|
||||
monkeypatch.setenv("TESM_SITE_KEY", "../../etc/nginx")
|
||||
with pytest.raises(ValueError):
|
||||
load_core_config(app_key="tesm", app_name="TESM", env_prefix="TESM_")
|
||||
|
||||
|
||||
def test_site_key_defaults_to_the_app_key():
|
||||
assert SysOps(app_key="tesm").site_key == "tesm"
|
||||
|
||||
|
||||
def test_self_signed_verb_is_known():
|
||||
assert "selfsigned-issue" in HELPER_VERBS
|
||||
|
||||
|
||||
def test_service_state_finds_unit_without_suffix(monkeypatch):
|
||||
"""``list-unit-files`` braucht ein Muster mit Endung.
|
||||
|
||||
Ohne sie fand die Abfrage nichts und der Zustand lautete
|
||||
"installiert: nein" -- auch bei laufendem Dienst. Auf der DHCP-Seite lud
|
||||
das zum Klick auf "Kea installieren" ein.
|
||||
"""
|
||||
import tesm_core.sysops as sysops_modul
|
||||
from tesm_core.sysops import Result, SysOps
|
||||
|
||||
# Ohne systemctl bricht die Abfrage vorher ab -- auf dem Entwicklungsrechner
|
||||
# gibt es keines.
|
||||
monkeypatch.setattr(sysops_modul.shutil, "which", lambda name: "/bin/" + name)
|
||||
|
||||
gesehen: list[list[str]] = []
|
||||
|
||||
class Attrappe(SysOps):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def read_command(self, argv):
|
||||
gesehen.append(list(argv))
|
||||
if "list-unit-files" in argv:
|
||||
treffer = "kea-dhcp4-server.service" in argv
|
||||
return Result(True, "kea-dhcp4-server.service enabled" if treffer else "", "")
|
||||
return Result(True, "active", "")
|
||||
|
||||
zustand = Attrappe().service_state("kea-dhcp4-server")
|
||||
assert zustand["installed"] == "ja", gesehen
|
||||
# Eine bereits vollstaendige Unit bleibt unveraendert.
|
||||
assert Attrappe().service_state("kea-dhcp4-server.service")["installed"] == "ja"
|
||||
Reference in New Issue
Block a user