Files
OPNexus/backend/tests/test_config_code_schema.py
T
Julian Boldt 4eea6424e7
Release / verify (push) Canceled after 0s
CI / checks (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / backend (push) Canceled after 0s
Release / test (push) Canceled after 0s
Release / publish (backend) (push) Canceled after 0s
Release / publish (frontend) (push) Canceled after 0s
Release / publish (proxy) (push) Canceled after 0s
Release / publish (updater) (push) Canceled after 0s
Release v1.32.0
- **Konfiguration als Code:** Unter **System › Konfiguration als Code** (und per `opnexus export` / `opnexus apply`) lassen sich Gerätegruppen, Hierarchiegruppen,
  Aliase, Filterregeln, Port-Forwards, 1:1-NAT, Outbound-NAT, Hierarchie-Regeln und Standort-Variablen als **eine YAML-Datei exportieren** (sortiert, ohne Zeitstempel,
  ohne Zugänge oder Schlüssel, für Git und Reviews) und wieder **importieren**: erst ein **Plan** (neu, geändert, entfernt, unverändert, blockiert, mit Hinweisen), dann
  das **Übernehmen** durch einen Administrator in einer Transaktion mit dem Token genau dieses Plans. **Der Import ändert nur die Entwürfe in OPNexus, nie eine Firewall**;
  ausgerollt wird wie immer über Vorschau → Ausrollen → Bestätigen. Ausgerollte oder noch verwendete Objekte entfernt er nie (Prune blockiert den Plan). Die Datei wird
  nur als Daten gelesen (keine Anker, Tags, Merge-Schlüssel, Mehrfach-Dokumente; 1 MiB, strenges Schema mit Zeile und Pfad bei Fehlern). Plan und Export gehen auch mit „Lesen“-Token
  und Nur-Lese-Konten, `opnexus apply --dry-run` liefert Exit-Codes für CI (0 / 2 / 3 / 4). Regeln und NAT-Objekte tragen dafür einen stabilen `key` (Migration, wird für
  Bestand automatisch vergeben). Gruppenänderungen stehen jetzt im Audit.
- **Rückweg zur vorherigen Firmware:** Vor jedem Firmware-Update und Major-Upgrade (manuell, geplant, Rollout, CARP-Übergabe) legt OPNexus auf der Firewall ein
  **ZFS-Boot-Environment** `opnexus-pre-<Version>-<Zeit>` als Rückweg-Punkt an (ohne den Punkt startet das Update nicht; ohne ZFS mit Hinweis). Neue Aktion **Zur vorherigen Firmware**
  (Host-Wartung): Vorschau, Hostname, Downtime-Bestätigung, vorher lokale Sicherung der Konfiguration, dann Aktivieren, Prüfung und Neustart (scheitert ein Schritt, wird das
  vorherige Boot-Environment wieder eingeschaltet). **Der Rückweg setzt auch die Konfiguration zurück** (live belegt): Ausrollvorgänge seit dem Punkt werden als unklar markiert,
  damit das nächste Ausrollen wirklich schreibt. Ein Rollout macht nie von selbst einen Rückweg.
- **Rollout-Gate: Erreichbarkeitsproben.** Bis zu zehn Ping-/TCP-Proben je Plan, von der Firewall selbst ausgeführt, vor der Welle und nach der Beobachtungszeit:
  vorher ok und jetzt nicht mehr hält den Rollout an, schon vorher gestört ist nur ein Hinweis. (Erreichbarkeit von der Firewall aus, nicht der Verkehr durch sie hindurch.)
- Migrationen 0030 (`config_key`) und 0031 (`gate_probes`).
2026-10-23 11:27:47 +02:00

126 lines
6.2 KiB
Python

"""Schema v1 (1.32 P3): strict, equal to the API limits, JSON Schema file up to date."""
import typing
from pathlib import Path
import pytest
from pydantic import ValidationError
from app.config_code import schema
from app.schemas.alias import AliasCreate
from app.schemas.one_to_one import OneToOneCreate
from app.schemas.outbound_nat import OutboundNatCreate
from app.schemas.policy_hierarchy import PolicyHierarchyRuleCreate
from app.schemas.port_forward import PortForwardCreate
from app.schemas.rule import RuleCreate
# (file model, API model, fields that exist only on one side)
PAIRS = [
(schema.RuleDoc, RuleCreate, {"key", "targets"}, {"target_host_ids", "target_group_ids"}),
(schema.PortForwardDoc, PortForwardCreate, {"key", "targets"}, {"target_host_ids", "target_group_ids", "policy_hierarchy_group_id"}),
(schema.OneToOneDoc, OneToOneCreate, {"key", "targets"}, {"target_host_ids", "target_group_ids", "policy_hierarchy_group_id"}),
(schema.OutboundNatDoc, OutboundNatCreate, {"key", "targets"}, {"target_host_ids", "target_group_ids", "policy_hierarchy_group_id"}),
(schema.HierarchyRuleDoc, PolicyHierarchyRuleCreate, {"key", "group"}, {"hierarchy_group_id"}),
(schema.AliasDoc, AliasCreate, {"entries", "targets"}, {"content", "target_host_ids", "target_group_ids", "policy_hierarchy_group_id"}),
]
@pytest.mark.parametrize("doc,api,only_doc,only_api", PAIRS, ids=[p[0].__name__ for p in PAIRS])
def test_file_model_has_the_same_fields_defaults_and_limits_as_the_api_model(doc, api, only_doc, only_api):
assert set(doc.model_fields) - only_doc == set(api.model_fields) - only_api
for name in set(doc.model_fields) - only_doc:
a, b = doc.model_fields[name], api.model_fields[name]
assert a.default == b.default, name
assert _limits(a) == _limits(b), name
def _limits(field) -> dict:
"""Length/pattern limits of a field, also when they sit on the str branch of `str | None`."""
metas = list(field.metadata)
for arg in typing.get_args(field.annotation):
metas += list(getattr(arg, "__metadata__", ()))
metas = [m for meta in metas for m in (meta.metadata if hasattr(meta, "metadata") else [meta])] # unwrap Field(...)
out = {}
for meta in metas:
for attr in ("min_length", "max_length", "pattern"):
if getattr(meta, attr, None) is not None:
out[attr] = getattr(meta, attr)
return out
def test_enumerations_match_the_api():
for doc, api, name in ((schema.RuleDoc, RuleCreate, "action"), (schema.PortForwardDoc, PortForwardCreate, "natreflection"),
(schema.OneToOneDoc, OneToOneCreate, "natreflection"), (schema.OneToOneDoc, OneToOneCreate, "type"),
(schema.AliasDoc, AliasCreate, "type"), (schema.HierarchyRuleDoc, PolicyHierarchyRuleCreate, "rulebase")):
assert doc.model_fields[name].annotation == api.model_fields[name].annotation, (doc.__name__, name)
def _rule(**over):
return {"key": "r-1", "description": "d", "interface": "lan", **over}
def test_unknown_fields_wrong_types_and_secret_ref_are_refused():
with pytest.raises(ValidationError):
schema.RuleDoc.model_validate(_rule(secret_ref="x"))
with pytest.raises(ValidationError):
schema.RuleDoc.model_validate(_rule(enabled="yes")) # strict: no truthy strings
with pytest.raises(ValidationError):
schema.RuleDoc.model_validate(_rule(source_port=True)) # bool is never a port
with pytest.raises(ValidationError):
schema.ConfigDoc.model_validate({"opnexus_config": 2})
def test_ports_accept_numbers_and_ranges_and_become_strings():
assert schema.RuleDoc.model_validate(_rule(destination_port=53)).destination_port == "53"
assert schema.RuleDoc.model_validate(_rule(destination_port="8000-8100")).destination_port == "8000-8100"
with pytest.raises(ValidationError):
schema.RuleDoc.model_validate(_rule(destination_port="1" * 33))
@pytest.mark.parametrize("bad", ["a\x00b", "a\x1b[31m", "a\x7fb", "a\rb", "a\x0bb"])
def test_control_characters_are_refused(bad):
with pytest.raises(ValidationError):
schema.RuleDoc.model_validate(_rule(description=bad))
with pytest.raises(ValidationError):
schema.AliasDoc.model_validate({"name": "A", "entries": [bad]})
with pytest.raises(ValidationError):
schema.RuleDoc.model_validate(_rule(interface=bad))
def test_descriptions_may_have_several_lines_and_padding_but_names_and_keys_not():
assert schema.RuleDoc.model_validate(_rule(description="a\n\tb ")).description == "a\n\tb "
assert schema.DeviceGroupDoc.model_validate({"name": "G", "description": " zwei\nZeilen "}).description == " zwei\nZeilen "
with pytest.raises(ValidationError):
schema.RuleDoc.model_validate(_rule(interface="a\nb")) # a newline only in free text
for bad in (" G", "G "):
with pytest.raises(ValidationError):
schema.DeviceGroupDoc.model_validate({"name": bad})
with pytest.raises(ValidationError):
schema.RuleDoc.model_validate(_rule(targets={"hosts": [" fw"]}))
@pytest.mark.parametrize("key", ["", "Upper", "-lead", "a b", "x" * 65, "ü"])
def test_keys_must_be_lowercase_slugs(key):
with pytest.raises(ValidationError):
schema.RuleDoc.model_validate(_rule(key=key))
def test_variable_names_and_values_use_the_same_rules_as_the_host_page():
ok = {"opnexus_config": 1, "variables": {"fw": {"LAN_NET": "10.0.0.0/24"}}}
assert schema.ConfigDoc.model_validate(ok).variables == {"fw": {"LAN_NET": "10.0.0.0/24"}}
for bad in ({"lan": "x"}, {"LAN": "{{X}}"}, {"LAN": "a\nb"}, {"LAN": ""}):
with pytest.raises(ValidationError):
schema.ConfigDoc.model_validate({"opnexus_config": 1, "variables": {"fw": bad}})
def test_a_missing_section_is_not_an_empty_section():
doc = schema.ConfigDoc.model_validate({"opnexus_config": 1, "aliases": []})
assert doc.aliases == [] and doc.rules is None
def test_checked_in_json_schema_is_current():
path = Path(schema.__file__).with_name(schema.SCHEMA_FILE)
assert path.read_text(encoding="utf-8") == schema.json_schema_text(), (
"Schema geändert: `docker compose run --rm --no-deps -v \"$PWD/backend:/app\" backend python -m app.config_code schema "
f"> backend/app/config_code/{schema.SCHEMA_FILE}`"
)