mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
C1: drop debian/compat (kept debhelper-compat (= 13) Build-Depends) — the package was the only one in the repo carrying both, which debhelper refuses to build. C2: cli._run() now returns rc=None for "did not execute" (OSError/timeout), mirroring observe._run_cmd's contract. _cmd_scan checks rc for both systemctl list-unit-files and lxc-ls and aborts rather than silently deriving LXC modules as runtime="native" from empty output; a non-root invocation (rc=0, empty output) is refused explicitly since it is indistinguishable from "no containers" by rc alone. C3: PROTECTED_IDS moves from scan.py to manifest.py (a schema property, not a profiler detail) and load_manifest() now forces protected=True for those ids regardless of what a shipped manifest declares, closing the gap where a sloppy module package could silently unprotect auth/aggregator/core/etc. Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
125 lines
4.9 KiB
Python
125 lines
4.9 KiB
Python
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
|
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
|
|
# Source-Disclosed License — All rights reserved except as expressly granted.
|
|
# See LICENCE-CMSD-1.0.md for terms.
|
|
import pytest
|
|
|
|
from api.diff import Change, ProtectedViolation, plan_changes
|
|
from api.manifest import Manifest, load_manifest
|
|
from api.observe import Actual
|
|
from api.state import Profile
|
|
|
|
|
|
def mk(mid, priority=50, protected=False):
|
|
return Manifest(id=mid, category="media", runtime="native", exposure="lan",
|
|
units=(f"secubox-{mid}.service",), priority=priority,
|
|
protected=protected)
|
|
|
|
|
|
def on():
|
|
return Actual(enabled=True, active=True)
|
|
|
|
|
|
def off():
|
|
return Actual(enabled=False, active=False)
|
|
|
|
|
|
def test_no_changes_when_already_converged():
|
|
ms = {"lyrion": mk("lyrion")}
|
|
prof = Profile(name="media", label="m", on=frozenset({"lyrion"}))
|
|
assert plan_changes(ms, prof, {}, {"lyrion": on()}) == []
|
|
|
|
|
|
def test_start_what_profile_wants_and_stop_what_it_does_not():
|
|
ms = {"lyrion": mk("lyrion"), "gitea": mk("gitea")}
|
|
prof = Profile(name="media", label="m", on=frozenset({"lyrion"}))
|
|
actual = {"lyrion": off(), "gitea": on()}
|
|
changes = plan_changes(ms, prof, {}, actual)
|
|
assert {(c.id, c.action) for c in changes} == {("lyrion", "start"), ("gitea", "stop")}
|
|
|
|
|
|
def test_all_stops_come_before_all_starts():
|
|
# La box a ~2 Go libres : allumer avant d'éteindre ferait un pic fatal.
|
|
ms = {"a": mk("a", priority=10), "b": mk("b", priority=90)}
|
|
prof = Profile(name="p", label="p", on=frozenset({"a"}))
|
|
changes = plan_changes(ms, prof, {}, {"a": off(), "b": on()})
|
|
assert [c.action for c in changes] == ["stop", "start"]
|
|
|
|
|
|
def test_stops_ordered_by_ascending_priority():
|
|
# Le moins prioritaire s'éteint en premier ; le plus prioritaire tient
|
|
# le plus longtemps.
|
|
ms = {"hi": mk("hi", priority=90), "lo": mk("lo", priority=10), "mid": mk("mid", priority=50)}
|
|
prof = Profile(name="p", label="p", on=frozenset())
|
|
actual = {k: on() for k in ms}
|
|
changes = plan_changes(ms, prof, {}, actual)
|
|
assert [c.id for c in changes] == ["lo", "mid", "hi"]
|
|
|
|
|
|
def test_starts_ordered_by_descending_priority():
|
|
ms = {"hi": mk("hi", priority=90), "lo": mk("lo", priority=10), "mid": mk("mid", priority=50)}
|
|
prof = Profile(name="p", label="p", on=frozenset({"hi", "lo", "mid"}))
|
|
actual = {k: off() for k in ms}
|
|
changes = plan_changes(ms, prof, {}, actual)
|
|
assert [c.id for c in changes] == ["hi", "mid", "lo"]
|
|
|
|
|
|
def test_protected_module_never_stopped():
|
|
# Refus, pas avertissement : sans ça un profil éteint l'auth et
|
|
# l'utilisateur perd tout moyen de revenir.
|
|
ms = {"auth": mk("auth", protected=True)}
|
|
prof = Profile(name="p", label="p", on=frozenset())
|
|
plan = plan_changes(ms, prof, {}, {"auth": on()})
|
|
assert plan == [] # protected → désiré ON → déjà ON → aucun changement
|
|
|
|
|
|
def test_protected_module_off_is_started_not_refused():
|
|
ms = {"auth": mk("auth", protected=True)}
|
|
prof = Profile(name="p", label="p", on=frozenset())
|
|
plan = plan_changes(ms, prof, {}, {"auth": off()})
|
|
assert [(c.id, c.action) for c in plan] == [("auth", "start")]
|
|
|
|
|
|
def test_pin_off_on_protected_module_is_refused():
|
|
ms = {"auth": mk("auth", protected=True)}
|
|
prof = Profile(name="p", label="p", on=frozenset())
|
|
with pytest.raises(ProtectedViolation):
|
|
plan_changes(ms, prof, {"auth": "off"}, {"auth": on()})
|
|
|
|
|
|
def test_pin_off_on_core_module_with_sloppy_manifest_is_still_refused(tmp_path):
|
|
# C3 review: un paquet secubox-auth qui shippe protected=false ne doit
|
|
# pas pouvoir désarmer la protection — load_manifest() force déjà True
|
|
# pour les PROTECTED_IDS, et plan_changes doit continuer d'en tenir
|
|
# compte end-to-end (chargement réel, pas juste le dataclass à la main).
|
|
p = tmp_path / "auth.toml"
|
|
p.write_text(
|
|
'id = "auth"\n'
|
|
'category = "security"\n'
|
|
'runtime = "native"\n'
|
|
'exposure = "internal"\n'
|
|
'units = ["secubox-auth.service"]\n'
|
|
'protected = false\n'
|
|
)
|
|
m = load_manifest(p)
|
|
assert m.protected is True # sanity: la garde de chargement a bien joué
|
|
ms = {"auth": m}
|
|
prof = Profile(name="p", label="p", on=frozenset())
|
|
with pytest.raises(ProtectedViolation):
|
|
plan_changes(ms, prof, {"auth": "off"}, {"auth": on()})
|
|
|
|
|
|
def test_change_carries_reason():
|
|
ms = {"gitea": mk("gitea")}
|
|
prof = Profile(name="media", label="m", on=frozenset())
|
|
c = plan_changes(ms, prof, {}, {"gitea": on()})[0]
|
|
assert c.reason and isinstance(c.reason, str)
|
|
|
|
|
|
def test_module_without_observation_is_skipped():
|
|
# Un manifeste sans observation (module absent de la board) ne doit pas
|
|
# produire de changement fantôme.
|
|
ms = {"ghost": mk("ghost")}
|
|
prof = Profile(name="p", label="p", on=frozenset({"ghost"}))
|
|
assert plan_changes(ms, prof, {}, {}) == []
|