Files
OPNexus/backend/tests/test_config_code_export.py
T
Julian Boldt 4eea6424e7
Release / verify (push) Waiting to run
Release / test (push) Blocked by required conditions
CI / backend (push) Blocked by required conditions
CI / frontend (push) Blocked by required conditions
CI / checks (push) Blocked by required conditions
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

130 lines
4.9 KiB
Python

"""Export (1.32 P3): deterministic, complete, schema-conform, and free of anything secret."""
import re
import pytest
import yaml
from app.config_code import schema
from app.config_code.export import canonical, dump_yaml, export_yaml
from app.database import SessionLocal
from app.models.rule import FirewallRule
from tests.config_code_seed import seed_everything
GOLDEN = '''\
# OPNexus-Konfiguration (Schema v1), exportiert von OPNexus {version}.
# Enthält keine Zugänge zu Firewalls, Benutzer, Tokens oder Schlüssel. Prüfen: opnexus apply DATEI --dry-run
opnexus_config: 1
device_groups:
- name: Filialen
description: alle Filialen
hosts:
- fw-filiale-1
- fw-filiale-2
policy_hierarchy_groups:
- name: Organisation
parent: null
description: ''
hosts: []
- name: Nord
parent: Organisation
description: Region Nord
hosts:
- fw-filiale-1
aliases:
- name: WEB_SERVERS
type: host
description: DMZ
enabled: true
entries:
- 10.20.30.10
- 203.0.113.20
targets:
hosts: []
groups:
- Filialen
hierarchy_group: null
'''
async def _export(**kw) -> str:
async with SessionLocal() as db:
return await export_yaml(db, **kw)
async def test_export_starts_with_the_fixed_header_and_the_expected_sections():
from app.version import APP_VERSION
await seed_everything()
text = await _export()
assert text.startswith(GOLDEN.format(version=APP_VERSION))
assert [k for k in yaml.safe_load(text) if k != "opnexus_config"] == list(schema.KINDS)
assert text.endswith("\n") and "\r" not in text
async def test_export_is_deterministic_and_validates_against_the_schema():
await seed_everything()
first, second = await _export(), await _export()
assert first == second
doc = schema.ConfigDoc.model_validate(yaml.safe_load(first)) # strict: unknown fields, wrong types, control chars
assert [r.key for r in doc.rules] == ["allow-dns-lan", "block-all"]
assert doc.variables == {"fw-filiale-1": {"LAN_NET": "10.20.30.0/24", "SITE": "Filiale Nord"}}
async def test_hierarchy_rules_are_ordered_by_group_rulebase_and_sequence_and_keep_placeholders_unresolved():
await seed_everything()
data = yaml.safe_load(await _export())
assert [r["key"] for r in data["policy_hierarchy_rules"]] == ["ph-block-bogons", "ph-allow-mgmt", "ph-post-deny"]
rule = next(r for r in data["rules"] if r["key"] == "allow-dns-lan")
assert rule["source_net"] == "{{LAN_NET}}" and rule["destination_port"] == "53"
async def test_ports_are_written_as_strings_so_they_survive_the_round_trip():
await seed_everything()
text = await _export()
assert re.search(r"destination_port: '53'", text)
assert schema.ConfigDoc.model_validate(yaml.safe_load(text)).rules[0].destination_port == "53"
async def test_export_never_contains_secrets_or_connection_data():
await seed_everything()
text = (await _export()).lower()
for forbidden in ("akia-secret-key", "secret-host.example", "https://", "api_key", "api_secret", "private key", "opx_", "password"):
assert forbidden not in text
async def test_kinds_limits_the_sections():
await seed_everything()
data = yaml.safe_load(await _export(kinds={"aliases", "rules"}))
assert list(data) == ["opnexus_config", "aliases", "rules"]
async def test_new_objects_get_a_key_and_an_empty_database_exports_empty_sections():
async with SessionLocal() as db:
rule = FirewallRule(description="x", interface="lan", direction="in")
db.add(rule)
await db.commit()
assert re.fullmatch(r"r-[0-9a-f]{12}", rule.config_key) and rule.config_key == f"r-{rule.id.hex[:12]}"
data = yaml.safe_load(await _export(kinds={"aliases", "variables"}))
assert data["aliases"] == [] and data["variables"] == {}
async def test_exporter_field_lists_match_the_schema():
"""A field added to the schema but not to the exporter (or vice versa) must fail loudly, not export silently wrong."""
await seed_everything()
async with SessionLocal() as db:
content = await canonical(db)
for kind, doc in (("rules", schema.RuleDoc), ("port_forwards", schema.PortForwardDoc), ("one_to_one", schema.OneToOneDoc),
("outbound_nat", schema.OutboundNatDoc), ("policy_hierarchy_rules", schema.HierarchyRuleDoc),
("aliases", schema.AliasDoc), ("device_groups", schema.DeviceGroupDoc),
("policy_hierarchy_groups", schema.HierarchyGroupDoc)):
assert all(list(item) == list(doc.model_fields) for item in content[kind]), kind
assert dump_yaml(content) == await _export()
async def test_objects_are_sorted_by_key_not_by_insertion_order():
async with SessionLocal() as db:
for key in ("zz-last", "aa-first", "mm-middle"):
db.add(FirewallRule(config_key=key, description=key, interface="lan", direction="in"))
await db.commit()
assert [r["key"] for r in yaml.safe_load(await _export())["rules"]] == ["aa-first", "mm-middle", "zz-last"]