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>
522 lines
19 KiB
Python
522 lines
19 KiB
Python
"""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"
|