mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-21 21:15:12 +00:00
Closes F10: the sim silently stored out-of-range/malformed scalar values
(e.g. vlan-9999, IP 999.888.777.x) that real APIC/NDO reject server-side.
New aci_sim/rest_aci/validators.py: table-driven RULES {(class,prop) -> validator}
(range/enum/IP·MAC-format/vlan-encap). Enforced in writes.py::_validate_planned
(APIC) and ndo/patch.py::apply_json_patch (NDO) before any store mutation.
Fail-safe default-allow: only registered (class,prop) validated; unknown pass.
Bad values now -> APIC Error 103 Malformed MO body. ~45 rules / ~18 classes.
Constraints sourced from vendored cisco.aci arg-specs + standard ACI ranges,
cross-checked against the real-machine var corpus for zero false-rejects
(regression: full create_tenant MS-TN1 + SF-TN1 chains stay green). +176 tests
(1098 passed). Referential integrity split out as F11 (needs real-APIC study).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zdUTqU9fvCCvF3uVGZsu1
169 lines
6.3 KiB
Python
169 lines
6.3 KiB
Python
"""F10 — server-side value validation, end-to-end through the APIC write
|
|
face (POST /api/mo/{dn}.json -> aci_sim/rest_aci/writes.py::_validate_planned
|
|
-> aci_sim/rest_aci/validators.py::RULES).
|
|
|
|
Complements tests/test_f10_validators.py (which exercises RULES entries
|
|
directly) by proving the FULL plumbing: a bad value 400s with the real APIC
|
|
error envelope and never lands in the store; the pre-existing 3
|
|
fvSubnet/l3extSubnet/vnsRedirectDest ip checks keep working byte-identically
|
|
now that they're migrated into the RULES registry
|
|
(tests/test_write_validation_and_children.py already covers that regression
|
|
directly — this file adds the NEW P1/P2 rules' end-to-end wiring)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from aci_sim.build.orchestrator import build_site
|
|
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
|
|
from aci_sim.topology.loader import load_topology
|
|
|
|
TOPO_PATH = "topology.yaml"
|
|
TENANT = "F10TEST-T1"
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def client():
|
|
topo = load_topology(TOPO_PATH)
|
|
site = topo.sites[0]
|
|
store = build_site(topo, site)
|
|
state = ApicSiteState(
|
|
name=site.name, site=site, topo=topo,
|
|
store=store, baseline=copy.deepcopy(store),
|
|
)
|
|
c = TestClient(make_apic_app(state))
|
|
resp = c.post(
|
|
"/api/aaaLogin.json",
|
|
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
|
|
)
|
|
assert resp.status_code == 200
|
|
_post(c, f"uni/tn-{TENANT}", {"fvTenant": {"attributes": {"name": TENANT}}})
|
|
return c
|
|
|
|
|
|
def _post(client, dn: str, body: dict):
|
|
return client.post(f"/api/mo/{dn}.json", json=body)
|
|
|
|
|
|
def _assert_not_in_store(client, cls: str, dn: str):
|
|
q = client.get(f"/api/class/{cls}.json").json()
|
|
assert not any(
|
|
i[cls]["attributes"]["dn"] == dn for i in q["imdata"]
|
|
), f"invalid {cls} must not land in the MIT"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The exact F10 probe finding: encap=vlan-9999 / rtrId=999.888.777.x now 400.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_f10_probe_bad_encap_rejected(client):
|
|
dn = f"uni/tn-{TENANT}/out-L3OUT1/lnodep-NP1/lifp-IP1/rspathL3OutAtt-[topology/pod-1/paths-101/pathep-[eth1/1]]"
|
|
r = _post(
|
|
client, dn,
|
|
{"l3extRsPathL3OutAtt": {"attributes": {"encap": "vlan-9999", "dn": dn}}},
|
|
)
|
|
assert r.status_code == 400
|
|
err = r.json()["imdata"][0]["error"]["attributes"]
|
|
assert "vlan-9999" in err["text"]
|
|
# app.py's post_mo handler catches WriteValidationError with code="103"
|
|
# (the design doc's "code 107" citation is for a different catch site —
|
|
# the GET-query FilterParseError handlers at get_class/get_mo).
|
|
assert err["code"] == "103"
|
|
_assert_not_in_store(client, "l3extRsPathL3OutAtt", dn)
|
|
|
|
|
|
def test_f10_probe_bad_rtrid_rejected(client):
|
|
dn = f"uni/tn-{TENANT}/out-L3OUT1/lnodep-NP1/rsnodeL3OutAtt-[topology/pod-1/node-101]"
|
|
r = _post(
|
|
client, dn,
|
|
{"l3extRsNodeL3OutAtt": {"attributes": {"rtrId": "999.888.777.241", "dn": dn}}},
|
|
)
|
|
assert r.status_code == 400
|
|
err = r.json()["imdata"][0]["error"]["attributes"]
|
|
assert "999.888.777.241" in err["text"]
|
|
_assert_not_in_store(client, "l3extRsNodeL3OutAtt", dn)
|
|
|
|
|
|
def test_f10_valid_encap_and_rtrid_accepted(client):
|
|
dn1 = f"uni/tn-{TENANT}/out-L3OUT1/lnodep-NP1/lifp-IP1/rspathL3OutAtt-[topology/pod-1/paths-101/pathep-[eth1/2]]"
|
|
r = _post(
|
|
client, dn1,
|
|
{"l3extRsPathL3OutAtt": {"attributes": {"encap": "vlan-51", "mtu": "9000", "dn": dn1}}},
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
dn2 = f"uni/tn-{TENANT}/out-L3OUT1/lnodep-NP1/rsnodeL3OutAtt-[topology/pod-1/node-102]"
|
|
r = _post(
|
|
client, dn2,
|
|
{"l3extRsNodeL3OutAtt": {"attributes": {"rtrId": "10.100.201.241", "dn": dn2}}},
|
|
)
|
|
assert r.status_code == 200
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# All-or-nothing: a bad value nested deep in a subtree must reject the WHOLE
|
|
# POST, leaving zero side effects (design §3.2 "preserves the existing
|
|
# all-or-nothing guarantee").
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_bad_nested_asn_rejects_whole_subtree(client):
|
|
peer_dn = (
|
|
f"uni/tn-{TENANT}/out-L3OUT1/lnodep-NP1/lifp-IP1/"
|
|
f"paths-101/pathep-[eth1/3]]/peerP-[10.100.0.5]"
|
|
)
|
|
body = {
|
|
"bgpPeerP": {
|
|
"attributes": {"addr": "10.100.0.5", "dn": peer_dn},
|
|
"children": [
|
|
{"bgpAsP": {"attributes": {"asn": "0"}}}, # illegal: asn must be >= 1
|
|
],
|
|
}
|
|
}
|
|
r = _post(client, peer_dn, body)
|
|
assert r.status_code == 400
|
|
# The parent bgpPeerP must NOT have landed either — all-or-nothing.
|
|
_assert_not_in_store(client, "bgpPeerP", peer_dn)
|
|
|
|
|
|
def test_good_nested_asn_accepted(client):
|
|
peer_dn = (
|
|
f"uni/tn-{TENANT}/out-L3OUT1/lnodep-NP1/lifp-IP1/"
|
|
f"paths-101/pathep-[eth1/4]]/peerP-[10.100.0.6]"
|
|
)
|
|
body = {
|
|
"bgpPeerP": {
|
|
"attributes": {"addr": "10.100.0.6", "ttl": "2", "dn": peer_dn},
|
|
"children": [
|
|
{"bgpAsP": {"attributes": {"asn": "65100"}}},
|
|
],
|
|
}
|
|
}
|
|
r = _post(client, peer_dn, body)
|
|
assert r.status_code == 200
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fail-safe: an unregistered class or property is never validated, even with
|
|
# a wild value.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_unregistered_class_prop_passes_through_unvalidated(client):
|
|
dn = f"uni/tn-{TENANT}/ap-AP1"
|
|
r = _post(client, dn, {"fvAp": {"attributes": {"name": "AP1", "someUnknownProp": "!!not-validated!!", "dn": dn}}})
|
|
assert r.status_code == 200
|
|
|
|
|
|
def test_delete_skips_validation_even_with_bad_leftover_shape(client):
|
|
dn = f"uni/tn-{TENANT}/out-L3OUT1/lnodep-NP1/lifp-IP1/rspathL3OutAtt-[topology/pod-1/paths-101/pathep-[eth1/9]]"
|
|
r = _post(client, dn, {"l3extRsPathL3OutAtt": {"attributes": {"encap": "vlan-51", "dn": dn}}})
|
|
assert r.status_code == 200
|
|
# A delete carries only dn/status — no encap attr to (mis)validate.
|
|
r = _post(client, dn, {"l3extRsPathL3OutAtt": {"attributes": {"dn": dn, "status": "deleted"}}})
|
|
assert r.status_code == 200
|