secubox-deb/packages/secubox-profiles/tests/test_diff.py
CyberMind-FR fc28ab7678 fix(profiles): unbuildable package, rc collision on scan, data-driven protection
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>
2026-07-17 13:14:02 +02:00

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, {}, {}) == []