mirror of
https://github.com/dtzp555-max/aci-sim.git
synced 2026-07-19 09:46:33 +00:00
aci-sim is a stateful REST simulator of a 2-site Cisco ACI fabric (per-site APIC + ND/NDO management planes) — for testing ACI automation (Ansible cisco.aci / cisco.mso, aci-py, custom REST clients) and CI gates without real hardware. Highlights: APIC + NDO REST surface served from one in-memory MIT built from a declarative topology.yaml; port mode + sandbox (real per-device IPs on :443) run modes; LLDP/CDP neighbor visibility; NDO->APIC deploy mirror (multi-site templates materialize onto the target sites' APIC stores); real-APIC fvBD default attributes; file-backed state save/restore; an `aci-sim` CLI (validate/show/graph/run/new/init/lldp); and an 888-test suite. History squashed for the public release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
718 lines
24 KiB
Python
718 lines
24 KiB
Python
"""Tests for the APIC REST simulation layer (Phase 5)."""
|
|
|
|
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" # run from project root
|
|
|
|
|
|
@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),
|
|
)
|
|
app = make_apic_app(state)
|
|
return TestClient(app)
|
|
|
|
|
|
def test_login(client):
|
|
resp = client.post(
|
|
"/api/aaaLogin.json",
|
|
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "aaaLogin" in data["imdata"][0]
|
|
token = data["imdata"][0]["aaaLogin"]["attributes"]["token"]
|
|
assert token
|
|
assert "APIC-cookie" in resp.cookies
|
|
|
|
|
|
def test_refresh(client):
|
|
resp = client.get("/api/aaaRefresh.json")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "aaaLogin" in data["imdata"][0]
|
|
|
|
|
|
def test_class_fabric_node(client):
|
|
resp = client.get("/api/class/fabricNode.json")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
# 6 switches (2 spine + 2 leaf + 2 border-leaf) + 1 APIC controller (PR-21 single-APIC default) = 7
|
|
assert data["totalCount"] == "7"
|
|
assert len(data["imdata"]) == 7
|
|
roles = [i["fabricNode"]["attributes"]["role"] for i in data["imdata"]]
|
|
assert sum(r in ("spine", "leaf") for r in roles) == 6
|
|
assert roles.count("controller") == 1
|
|
|
|
|
|
def test_class_fault_filter(client):
|
|
resp = client.get(
|
|
'/api/class/faultInst.json?query-target-filter=eq(faultInst.severity,"minor")'
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
# Topology seeds exactly one "minor" faultInst (F0532 on node-101) — a
|
|
# regression that silently empties imdata (e.g. 200-with-empty-imdata)
|
|
# must not pass silently.
|
|
assert int(data["totalCount"]) == 1
|
|
assert len(data["imdata"]) == 1
|
|
for item in data["imdata"]:
|
|
assert item["faultInst"]["attributes"]["severity"] == "minor"
|
|
|
|
|
|
def test_fault_inst_rows_carry_seeded_severities(client):
|
|
# Stable premise for the PR-1 subtree+filter regression tests: the
|
|
# topology seeds exactly two faultInst rows (severity=minor F0532 on
|
|
# node-101, severity=warning F0554 on node-102). Assert both are present
|
|
# with their seeded severity so a regression that drops/renames severity
|
|
# values is caught here rather than only in the filter-specific tests.
|
|
resp = client.get("/api/class/faultInst.json")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert int(data["totalCount"]) == 2
|
|
assert len(data["imdata"]) == 2
|
|
severities = {i["faultInst"]["attributes"]["severity"] for i in data["imdata"]}
|
|
assert severities == {"minor", "warning"}
|
|
codes = {i["faultInst"]["attributes"]["code"] for i in data["imdata"]}
|
|
assert codes == {"F0532", "F0554"}
|
|
|
|
|
|
def test_mo_subtree_flat(client):
|
|
resp = client.get(
|
|
"/api/mo/uni/tn-MS-TN1.json?query-target=subtree&target-subtree-class=fvBD"
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
# tn-MS-TN1 seeds at least one BD in the topology fixture; a regression
|
|
# returning 200-with-empty-imdata must fail loudly rather than pass this
|
|
# now-vacuous loop.
|
|
assert int(data["totalCount"]) > 0
|
|
assert len(data["imdata"]) > 0
|
|
for item in data["imdata"]:
|
|
assert "fvBD" in item
|
|
|
|
|
|
def test_bracket_dn(client):
|
|
resp = client.get("/api/class/fvSubnet.json")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
if not data["imdata"]:
|
|
pytest.skip("no subnets in topology")
|
|
dn = data["imdata"][0]["fvSubnet"]["attributes"]["dn"]
|
|
resp2 = client.get(f"/api/mo/{dn}.json")
|
|
assert resp2.status_code == 200
|
|
d2 = resp2.json()
|
|
assert len(d2["imdata"]) == 1
|
|
|
|
|
|
def test_node_scoped_fault(client):
|
|
resp = client.get("/api/node/class/topology/pod-1/node-101/faultInst.json")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert isinstance(data["imdata"], list)
|
|
assert isinstance(data["totalCount"], str)
|
|
|
|
|
|
def test_write_readback(client):
|
|
resp = client.post(
|
|
"/api/mo/uni/tn-NEW.json",
|
|
json={"fvTenant": {"attributes": {"name": "NEW", "dn": "uni/tn-NEW", "descr": "test"}}},
|
|
)
|
|
assert resp.status_code == 200
|
|
resp2 = client.get("/api/class/fvTenant.json")
|
|
names = [item["fvTenant"]["attributes"]["name"] for item in resp2.json()["imdata"]]
|
|
assert "NEW" in names
|
|
|
|
|
|
def test_add_leaf_reaction(client):
|
|
resp = client.post(
|
|
"/api/mo/uni/controller/nodeidentpol/nodep-901.json",
|
|
json={
|
|
"fabricNodeIdentP": {
|
|
"attributes": {
|
|
"dn": "uni/controller/nodeidentpol/nodep-901",
|
|
"name": "leaf-901",
|
|
"nodeType": "leaf",
|
|
}
|
|
}
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
resp2 = client.get("/api/class/fabricNode.json")
|
|
ids = [item["fabricNode"]["attributes"]["id"] for item in resp2.json()["imdata"]]
|
|
assert "901" in ids
|
|
|
|
|
|
def test_missing_mo_returns_200_empty(client):
|
|
# PR-9 FEATURE 6: real APIC (and cisco.aci's GET-before-POST existence
|
|
# check) requires 200 + empty imdata for a well-formed-but-absent DN, not
|
|
# 404 — see tests/test_pr9_ansible.py for the verified module-source
|
|
# rationale. This was test_missing_mo_404 pre-PR-9.
|
|
resp = client.get("/api/mo/uni/tn-DOESNOTEXIST.json")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["totalCount"] == "0"
|
|
assert data["imdata"] == []
|
|
|
|
|
|
# -- B2 regression: comparison filters work, malformed filters are 400 not 500 --
|
|
|
|
|
|
def test_ne_filter_returns_200_not_500(client):
|
|
# ne/gt/lt/ge/le/bw used to hit `raise ValueError` -> uncaught -> HTTP 500.
|
|
resp = client.get(
|
|
'/api/class/fabricNode.json?query-target-filter=ne(fabricNode.role,"controller")'
|
|
)
|
|
assert resp.status_code == 200
|
|
roles = [i["fabricNode"]["attributes"]["role"] for i in resp.json()["imdata"]]
|
|
assert roles and "controller" not in roles
|
|
|
|
|
|
def test_gt_numeric_filter_returns_200(client):
|
|
resp = client.get(
|
|
'/api/class/fabricNode.json?query-target-filter=gt(fabricNode.id,"200")'
|
|
)
|
|
assert resp.status_code == 200
|
|
ids = [int(i["fabricNode"]["attributes"]["id"]) for i in resp.json()["imdata"]]
|
|
assert ids and all(i > 200 for i in ids)
|
|
|
|
|
|
def test_malformed_filter_returns_apic_400_not_500(client):
|
|
resp = client.get(
|
|
'/api/class/fabricNode.json?query-target-filter=frobnicate(fabricNode.id,"1")'
|
|
)
|
|
assert resp.status_code == 400
|
|
err = resp.json()["imdata"][0]["error"]["attributes"]
|
|
assert err["code"] == "107"
|
|
assert "frobnicate" in err["text"]
|
|
|
|
|
|
# -- PR-1 F1 regression: query-target=subtree + query-target-filter on a
|
|
# descendant-only attribute must filter the EXPANDED scope, not the root --
|
|
|
|
|
|
def test_subtree_filter_on_descendant_attr_mo_query(client):
|
|
# topology/pod-1/node-101 has a seeded faultInst descendant (severity=minor)
|
|
# at .../local/svc-policyelem-id-0/uni/fault-F0532. Filtering on
|
|
# faultInst.severity used to pre-filter the fabricNode root (which lacks a
|
|
# "severity" attr → False) BEFORE subtree expansion, always returning empty
|
|
# imdata regardless of matching descendants.
|
|
resp = client.get(
|
|
'/api/mo/topology/pod-1/node-101.json'
|
|
'?query-target=subtree&target-subtree-class=faultInst'
|
|
'&query-target-filter=eq(faultInst.severity,"minor")'
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert int(data["totalCount"]) > 0
|
|
for item in data["imdata"]:
|
|
assert "faultInst" in item
|
|
assert item["faultInst"]["attributes"]["severity"] == "minor"
|
|
|
|
|
|
def test_subtree_filter_on_descendant_attr_class_query(client):
|
|
resp = client.get(
|
|
'/api/class/faultInst.json?query-target-filter=eq(faultInst.severity,"minor")'
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert int(data["totalCount"]) > 0
|
|
for item in data["imdata"]:
|
|
assert item["faultInst"]["attributes"]["severity"] == "minor"
|
|
|
|
|
|
# -- PR-1 F2 regression: non-numeric / out-of-range page & page-size -> APIC 400 --
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"query",
|
|
[
|
|
"page=abc",
|
|
"page-size=abc",
|
|
"page-size=-1",
|
|
"page-size=0",
|
|
],
|
|
)
|
|
def test_bad_page_params_return_apic_400(client, query):
|
|
resp = client.get(f"/api/class/fabricNode.json?{query}")
|
|
assert resp.status_code == 400
|
|
body = resp.json()
|
|
assert "imdata" in body
|
|
assert "detail" not in body
|
|
assert "error" in body["imdata"][0]
|
|
|
|
|
|
def test_bad_page_size_return_apic_400_on_mo_query(client):
|
|
resp = client.get("/api/mo/uni/tn-MS-TN1.json?page-size=-1")
|
|
assert resp.status_code == 400
|
|
body = resp.json()
|
|
assert "imdata" in body
|
|
assert "detail" not in body
|
|
|
|
|
|
def test_bad_page_size_return_apic_400_on_node_scoped_query(client):
|
|
resp = client.get(
|
|
"/api/node/class/topology/pod-1/node-101/faultInst.json?page-size=abc"
|
|
)
|
|
assert resp.status_code == 400
|
|
body = resp.json()
|
|
assert "imdata" in body
|
|
assert "detail" not in body
|
|
|
|
|
|
# -- PR-1 F3 regression: strict filter parsing (trailing tokens, zero-arg composites) --
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"expr",
|
|
[
|
|
'eq(fabricNode.role,"spine"),eq(fabricNode.role,"leaf")', # comma-joined double filter
|
|
'eq(fabricNode.role,"spine"))', # unbalanced trailing paren
|
|
"or()", # zero-arg or
|
|
"and()", # zero-arg and
|
|
],
|
|
)
|
|
def test_strict_filter_parsing_returns_apic_400(client, expr):
|
|
resp = client.get(f"/api/class/fabricNode.json?query-target-filter={expr}")
|
|
assert resp.status_code == 400
|
|
err = resp.json()["imdata"][0]["error"]["attributes"]
|
|
assert err["code"] == "107"
|
|
|
|
|
|
# -- PR-1 numeric comparison guard: kills lexical-only mutation on bw/gt --
|
|
|
|
|
|
def test_bw_wide_numeric_range_matches_non_controller_nodes(client):
|
|
# "150" < "99" lexically, so a lexical-only bw would empty the range;
|
|
# numerically 99 <= id <= 150 must match all 4 LAB1 leaf/border-leaf nodes.
|
|
resp = client.get(
|
|
'/api/class/fabricNode.json?query-target-filter=bw(fabricNode.id,"99","150")'
|
|
)
|
|
assert resp.status_code == 200
|
|
ids = {i["fabricNode"]["attributes"]["id"] for i in resp.json()["imdata"]}
|
|
assert ids == {"101", "102", "103", "104"}
|
|
|
|
|
|
def test_gt_numeric_filter_matches_all_non_controller_nodes(client):
|
|
resp = client.get(
|
|
'/api/class/fabricNode.json?query-target-filter=gt(fabricNode.id,"99")'
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
# Controllers are id 1..N (<99); every id > 99 must be a non-controller node.
|
|
# (Don't assert an exact count here — the shared module-scoped `client`
|
|
# fixture may have accumulated extra nodes from earlier write tests.)
|
|
roles = [i["fabricNode"]["attributes"]["role"] for i in data["imdata"]]
|
|
assert roles
|
|
assert "controller" not in roles
|
|
|
|
|
|
# -- PR-2 R1: atomic validate-then-commit POST (no partial writes on 400) --
|
|
|
|
|
|
def test_malformed_child_rejects_with_400_and_writes_nothing(client):
|
|
resp = client.post(
|
|
"/api/mo/uni/tn-AtomT.json",
|
|
json={
|
|
"fvTenant": {
|
|
"attributes": {"name": "AtomT", "dn": "uni/tn-AtomT"},
|
|
"children": ["not-a-dict"],
|
|
}
|
|
},
|
|
)
|
|
assert resp.status_code == 400
|
|
err = resp.json()["imdata"][0]["error"]["attributes"]
|
|
assert err["code"] == "103"
|
|
|
|
resp2 = client.get("/api/mo/uni/tn-AtomT.json")
|
|
assert resp2.status_code == 200
|
|
assert resp2.json()["totalCount"] == "0"
|
|
|
|
|
|
def test_valid_first_child_then_malformed_second_child_rejects_everything(client):
|
|
resp = client.post(
|
|
"/api/mo/uni/tn-AtomT2.json",
|
|
json={
|
|
"fvTenant": {
|
|
"attributes": {"name": "AtomT2", "dn": "uni/tn-AtomT2"},
|
|
"children": [
|
|
{"fvCtx": {"attributes": {"name": "goodctx"}}},
|
|
["not-a-dict"],
|
|
],
|
|
}
|
|
},
|
|
)
|
|
assert resp.status_code == 400
|
|
err = resp.json()["imdata"][0]["error"]["attributes"]
|
|
assert err["code"] == "103"
|
|
|
|
# Neither the tenant nor the valid first child persisted.
|
|
resp2 = client.get("/api/mo/uni/tn-AtomT2.json")
|
|
assert resp2.status_code == 200
|
|
assert resp2.json()["totalCount"] == "0"
|
|
resp3 = client.get("/api/mo/uni/tn-AtomT2/ctx-goodctx.json")
|
|
assert resp3.status_code == 200
|
|
assert resp3.json()["totalCount"] == "0"
|
|
|
|
|
|
def test_malformed_grandchild_key_count_rejects_with_400_and_writes_nothing(client):
|
|
# A child_entry dict with != 1 key is also a shape violation.
|
|
resp = client.post(
|
|
"/api/mo/uni/tn-AtomT3.json",
|
|
json={
|
|
"fvTenant": {
|
|
"attributes": {"name": "AtomT3", "dn": "uni/tn-AtomT3"},
|
|
"children": [{"fvCtx": {"attributes": {"name": "c1"}}, "fvBD": {"attributes": {"name": "b1"}}}],
|
|
}
|
|
},
|
|
)
|
|
assert resp.status_code == 400
|
|
resp2 = client.get("/api/mo/uni/tn-AtomT3.json")
|
|
assert resp2.status_code == 200
|
|
assert resp2.json()["totalCount"] == "0"
|
|
|
|
|
|
# -- PR-2 R2: canonical RN synthesis for dn-less children --
|
|
|
|
|
|
def test_child_bd_without_dn_lands_on_canonical_BD_dn(client):
|
|
resp = client.post(
|
|
"/api/mo/uni/tn-RnT.json",
|
|
json={
|
|
"fvTenant": {
|
|
"attributes": {"name": "RnT", "dn": "uni/tn-RnT"},
|
|
"children": [{"fvBD": {"attributes": {"name": "bd1"}}}],
|
|
}
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
ok = client.get("/api/mo/uni/tn-RnT/BD-bd1.json")
|
|
assert ok.status_code == 200
|
|
assert ok.json()["imdata"][0]["fvBD"]["attributes"]["name"] == "bd1"
|
|
|
|
# The old '{cls}-{name}' heuristic must NOT have been used.
|
|
stale = client.get("/api/mo/uni/tn-RnT/fvBD-bd1.json")
|
|
assert stale.status_code == 200
|
|
assert stale.json()["totalCount"] == "0"
|
|
|
|
|
|
def test_nested_ap_epg_without_dn_land_on_canonical_dns(client):
|
|
resp = client.post(
|
|
"/api/mo/uni/tn-RnT2.json",
|
|
json={
|
|
"fvTenant": {
|
|
"attributes": {"name": "RnT2", "dn": "uni/tn-RnT2"},
|
|
"children": [
|
|
{
|
|
"fvAp": {
|
|
"attributes": {"name": "ap1"},
|
|
"children": [{"fvAEPg": {"attributes": {"name": "epg1"}}}],
|
|
}
|
|
}
|
|
],
|
|
}
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
ap = client.get("/api/mo/uni/tn-RnT2/ap-ap1.json")
|
|
assert ap.status_code == 200
|
|
|
|
epg = client.get("/api/mo/uni/tn-RnT2/ap-ap1/epg-epg1.json")
|
|
assert epg.status_code == 200
|
|
assert epg.json()["imdata"][0]["fvAEPg"]["attributes"]["name"] == "epg1"
|
|
|
|
# Old heuristics must not have created a shadow object.
|
|
stale_ap = client.get("/api/mo/uni/tn-RnT2/fvAp-ap1.json")
|
|
assert stale_ap.status_code == 200
|
|
assert stale_ap.json()["totalCount"] == "0"
|
|
stale_epg = client.get("/api/mo/uni/tn-RnT2/ap-ap1/fvAEPg-epg1.json")
|
|
assert stale_epg.status_code == 200
|
|
assert stale_epg.json()["totalCount"] == "0"
|
|
|
|
|
|
def test_subnet_without_dn_uses_bracketed_ip_rn(client):
|
|
resp = client.post(
|
|
"/api/mo/uni/tn-RnT3.json",
|
|
json={
|
|
"fvTenant": {
|
|
"attributes": {"name": "RnT3", "dn": "uni/tn-RnT3"},
|
|
"children": [
|
|
{
|
|
"fvBD": {
|
|
"attributes": {"name": "bd1"},
|
|
"children": [
|
|
{"fvSubnet": {"attributes": {"ip": "10.20.30.1/24"}}}
|
|
],
|
|
}
|
|
}
|
|
],
|
|
}
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
subnet = client.get("/api/mo/uni/tn-RnT3/BD-bd1/subnet-[10.20.30.1/24].json")
|
|
assert subnet.status_code == 200
|
|
assert subnet.json()["imdata"][0]["fvSubnet"]["attributes"]["ip"] == "10.20.30.1/24"
|
|
|
|
|
|
def test_dnless_child_then_explicit_canonical_dn_does_not_duplicate(client):
|
|
# First POST without dn (synthesizes canonical dn), then POST again with
|
|
# the explicit canonical dn — must upsert the SAME object, not duplicate.
|
|
resp1 = client.post(
|
|
"/api/mo/uni/tn-RnT4.json",
|
|
json={
|
|
"fvTenant": {
|
|
"attributes": {"name": "RnT4", "dn": "uni/tn-RnT4"},
|
|
"children": [{"fvBD": {"attributes": {"name": "bd1"}}}],
|
|
}
|
|
},
|
|
)
|
|
assert resp1.status_code == 200
|
|
|
|
resp2 = client.post(
|
|
"/api/mo/uni/tn-RnT4/BD-bd1.json",
|
|
json={
|
|
"fvBD": {
|
|
"attributes": {
|
|
"dn": "uni/tn-RnT4/BD-bd1",
|
|
"name": "bd1",
|
|
"descr": "explicit dn write",
|
|
}
|
|
}
|
|
},
|
|
)
|
|
assert resp2.status_code == 200
|
|
|
|
cls_resp = client.get('/api/class/fvBD.json?query-target-filter=eq(fvBD.name,"bd1")')
|
|
assert cls_resp.status_code == 200
|
|
matches = [
|
|
i for i in cls_resp.json()["imdata"] if i["fvBD"]["attributes"]["dn"] == "uni/tn-RnT4/BD-bd1"
|
|
]
|
|
assert len(matches) == 1
|
|
assert matches[0]["fvBD"]["attributes"]["descr"] == "explicit dn write"
|
|
|
|
|
|
# -- PR-2: valid multi-level write still works end-to-end --
|
|
|
|
|
|
def test_valid_multilevel_write_reads_back_every_level(client):
|
|
resp = client.post(
|
|
"/api/mo/uni/tn-FullT.json",
|
|
json={
|
|
"fvTenant": {
|
|
"attributes": {"name": "FullT", "dn": "uni/tn-FullT"},
|
|
"children": [
|
|
{
|
|
"fvCtx": {
|
|
"attributes": {"name": "vrf1"},
|
|
}
|
|
},
|
|
{
|
|
"fvBD": {
|
|
"attributes": {"name": "bd1"},
|
|
"children": [
|
|
{"fvRsCtx": {"attributes": {"tnFvCtxName": "vrf1"}}},
|
|
{"fvSubnet": {"attributes": {"ip": "192.168.1.1/24"}}},
|
|
],
|
|
}
|
|
},
|
|
{
|
|
"fvAp": {
|
|
"attributes": {"name": "ap1"},
|
|
"children": [
|
|
{
|
|
"fvAEPg": {
|
|
"attributes": {"name": "epg1"},
|
|
"children": [
|
|
{"fvRsBd": {"attributes": {"tnFvBDName": "bd1"}}}
|
|
],
|
|
}
|
|
}
|
|
],
|
|
}
|
|
},
|
|
],
|
|
}
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
tenant = client.get("/api/mo/uni/tn-FullT.json")
|
|
assert tenant.status_code == 200
|
|
|
|
ctx = client.get("/api/mo/uni/tn-FullT/ctx-vrf1.json")
|
|
assert ctx.status_code == 200
|
|
assert ctx.json()["imdata"][0]["fvCtx"]["attributes"]["name"] == "vrf1"
|
|
|
|
bd = client.get("/api/mo/uni/tn-FullT/BD-bd1.json")
|
|
assert bd.status_code == 200
|
|
|
|
rsctx = client.get("/api/mo/uni/tn-FullT/BD-bd1/rsctx.json")
|
|
assert rsctx.status_code == 200
|
|
assert rsctx.json()["imdata"][0]["fvRsCtx"]["attributes"]["tnFvCtxName"] == "vrf1"
|
|
|
|
subnet = client.get("/api/mo/uni/tn-FullT/BD-bd1/subnet-[192.168.1.1/24].json")
|
|
assert subnet.status_code == 200
|
|
|
|
ap = client.get("/api/mo/uni/tn-FullT/ap-ap1.json")
|
|
assert ap.status_code == 200
|
|
|
|
epg = client.get("/api/mo/uni/tn-FullT/ap-ap1/epg-epg1.json")
|
|
assert epg.status_code == 200
|
|
|
|
rsbd = client.get("/api/mo/uni/tn-FullT/ap-ap1/epg-epg1/rsbd.json")
|
|
assert rsbd.status_code == 200
|
|
assert rsbd.json()["imdata"][0]["fvRsBd"]["attributes"]["tnFvBDName"] == "bd1"
|
|
|
|
|
|
# -- fvBD create-time defaults (fidelity fix: real APIC fills object defaults
|
|
# -- on CREATE so a sparse POST still reads back full BD-detail attributes) --
|
|
|
|
|
|
def test_posted_bd_gets_create_time_defaults(client):
|
|
resp = client.post(
|
|
"/api/mo/uni/tn-BdDefT.json",
|
|
json={
|
|
"fvTenant": {
|
|
"attributes": {"name": "BdDefT", "dn": "uni/tn-BdDefT"},
|
|
"children": [
|
|
{
|
|
"fvBD": {
|
|
"attributes": {
|
|
"name": "bd1",
|
|
"unicastRoute": "yes",
|
|
"unkMacUcastAct": "proxy",
|
|
}
|
|
}
|
|
}
|
|
],
|
|
}
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
bd = client.get("/api/mo/uni/tn-BdDefT/BD-bd1.json")
|
|
assert bd.status_code == 200
|
|
attrs = bd.json()["imdata"][0]["fvBD"]["attributes"]
|
|
assert attrs["epMoveDetectMode"] == ""
|
|
assert attrs["hostBasedRouting"] == "no"
|
|
assert attrs["arpFlood"] == "no"
|
|
assert attrs["unkMcastAct"] == "flood"
|
|
assert attrs["v6unkMcastAct"] == "nd"
|
|
assert attrs["multiDstPktAct"] == "bd-flood"
|
|
assert attrs["limitIpLearnToSubnets"] == "yes"
|
|
assert attrs["ipLearning"] == "yes"
|
|
assert attrs["mac"] == "00:22:BD:F8:19:FF"
|
|
assert attrs["type"] == "regular"
|
|
# Posted attrs still present alongside the filled-in defaults.
|
|
assert attrs["unicastRoute"] == "yes"
|
|
assert attrs["unkMacUcastAct"] == "proxy"
|
|
|
|
|
|
def test_posted_bd_explicit_attr_wins_over_default(client):
|
|
resp = client.post(
|
|
"/api/mo/uni/tn-BdDefT2.json",
|
|
json={
|
|
"fvTenant": {
|
|
"attributes": {"name": "BdDefT2", "dn": "uni/tn-BdDefT2"},
|
|
"children": [
|
|
{"fvBD": {"attributes": {"name": "bd1", "arpFlood": "yes"}}}
|
|
],
|
|
}
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
bd = client.get("/api/mo/uni/tn-BdDefT2/BD-bd1.json")
|
|
assert bd.status_code == 200
|
|
assert bd.json()["imdata"][0]["fvBD"]["attributes"]["arpFlood"] == "yes"
|
|
|
|
|
|
def test_bd_partial_update_does_not_reset_to_defaults(client):
|
|
# Create with an explicit non-default arpFlood.
|
|
resp1 = client.post(
|
|
"/api/mo/uni/tn-BdDefT3.json",
|
|
json={
|
|
"fvTenant": {
|
|
"attributes": {"name": "BdDefT3", "dn": "uni/tn-BdDefT3"},
|
|
"children": [
|
|
{"fvBD": {"attributes": {"name": "bd1", "arpFlood": "yes"}}}
|
|
],
|
|
}
|
|
},
|
|
)
|
|
assert resp1.status_code == 200
|
|
|
|
# Re-POST the SAME dn without arpFlood — a partial update that changes
|
|
# only descr. Real APIC preserves the prior arpFlood="yes"; it must NOT
|
|
# be reset to the create-time default "no".
|
|
resp2 = client.post(
|
|
"/api/mo/uni/tn-BdDefT3/BD-bd1.json",
|
|
json={
|
|
"fvBD": {
|
|
"attributes": {
|
|
"dn": "uni/tn-BdDefT3/BD-bd1",
|
|
"name": "bd1",
|
|
"descr": "partial update",
|
|
}
|
|
}
|
|
},
|
|
)
|
|
assert resp2.status_code == 200
|
|
|
|
bd = client.get("/api/mo/uni/tn-BdDefT3/BD-bd1.json")
|
|
assert bd.status_code == 200
|
|
attrs = bd.json()["imdata"][0]["fvBD"]["attributes"]
|
|
assert attrs["arpFlood"] == "yes"
|
|
assert attrs["descr"] == "partial update"
|
|
|
|
|
|
def test_deleted_bd_is_removed_not_resurrected_with_defaults(client):
|
|
resp1 = client.post(
|
|
"/api/mo/uni/tn-BdDefT4.json",
|
|
json={
|
|
"fvTenant": {
|
|
"attributes": {"name": "BdDefT4", "dn": "uni/tn-BdDefT4"},
|
|
"children": [{"fvBD": {"attributes": {"name": "bd1"}}}],
|
|
}
|
|
},
|
|
)
|
|
assert resp1.status_code == 200
|
|
|
|
resp2 = client.post(
|
|
"/api/mo/uni/tn-BdDefT4/BD-bd1.json",
|
|
json={
|
|
"fvBD": {
|
|
"attributes": {
|
|
"dn": "uni/tn-BdDefT4/BD-bd1",
|
|
"status": "deleted",
|
|
}
|
|
}
|
|
},
|
|
)
|
|
assert resp2.status_code == 200
|
|
|
|
gone = client.get("/api/mo/uni/tn-BdDefT4/BD-bd1.json")
|
|
assert gone.status_code == 200
|
|
assert gone.json()["totalCount"] == "0"
|