Files
aci-sim/tests/test_pr10_ansible_gaps.py
T
taodengandClaude Fable 5 7e9a175ce6 Initial public release — aci-sim v0.16.0
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>
2026-07-05 20:06:36 +10:00

278 lines
9.6 KiB
Python

"""Regression tests for PR-10: cisco.aci/cisco.mso E2E-run gap fixes.
Triaged from a live aci-ansible E2E run whose failures were checked against
real-gear logs (see docs/CONTRACT.md §7/§11 and CHANGELOG 0.2.1). Covers:
1. GET/POST/DELETE via /api/node/mo/{dn} behave identically to the
canonical /api/mo/{dn} — real APIC serves /api/node/mo/{dn}.json as a
full alias (API Inspector / aci_rest form); the sim only registered
/api/mo/*, so cisco.aci calls hitting the alias got FastAPI's default
404 {"detail":"Not Found"} (11 E2E failures).
2. An unmatched /api/* path returns an APIC-shaped 400 error envelope
(imdata/error/code), never FastAPI's {"detail":...} shape.
3. NDO /mso/api/v1/sites site names equal the topology's fabric names
(e.g. "LAB1-IT-ACI"), not the short internal site name ("LAB1") —
verified against a real-gear cisco.mso.mso_tenant run that only
accepted the fabric-name form.
4. GET /mso/api/v1/schemas/list-identity returns every seeded schema with
id + displayName, and is NOT shadowed by the parameterized
/mso/api/v1/schemas/{schema_id} route.
5. GET /api/v1/tenants (cisco.mso.ndo_template's tenant prereq lookup)
returns the NDO tenants list.
"""
from __future__ import annotations
import copy
import pytest
from fastapi.testclient import TestClient
from aci_sim.build.orchestrator import build_site
from aci_sim.ndo.app import make_ndo_app
from aci_sim.ndo.model import build_ndo_model
from aci_sim.rest_aci.app import ApicSiteState, make_apic_app
from aci_sim.topology.loader import load_topology
TOPO_PATH = "topology.yaml"
# ---------------------------------------------------------------------------
# APIC fixtures
# ---------------------------------------------------------------------------
def _new_apic_client() -> TestClient:
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),
)
return TestClient(make_apic_app(state))
def _logged_in_apic_client() -> TestClient:
c = _new_apic_client()
resp = c.post(
"/api/aaaLogin.json",
json={"aaaUser": {"attributes": {"name": "admin", "pwd": "cisco"}}},
)
assert resp.status_code == 200
return c
# ---------------------------------------------------------------------------
# NDO fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def topo():
return load_topology(TOPO_PATH)
@pytest.fixture(scope="module")
def ndo_client(topo):
state = build_ndo_model(topo)
app = make_ndo_app(state)
with TestClient(app) as c:
yield c
@pytest.fixture(scope="module")
def ndo_token(ndo_client):
resp = ndo_client.post(
"/api/v1/auth/login",
json={"userName": "admin", "userPasswd": "cisco123", "domain": "local"},
)
assert resp.status_code == 200
return resp.json()["token"]
# ---------------------------------------------------------------------------
# FIX A — /api/node/mo alias
# ---------------------------------------------------------------------------
def test_node_mo_alias_get_post_delete_round_trip():
"""POST/GET/DELETE via /api/node/mo/{dn} must behave identically to the
canonical /api/mo/{dn} route — create via the node-alias, read back via
the canonical path, then delete via the alias and confirm removal.
"""
c = _logged_in_apic_client()
# Create via the node-alias.
post_resp = c.post(
"/api/node/mo/uni/tn-PR10AliasTest.json",
json={
"fvTenant": {
"attributes": {"name": "PR10AliasTest", "dn": "uni/tn-PR10AliasTest"}
}
},
)
assert post_resp.status_code == 200, post_resp.text
assert post_resp.json()["imdata"][0]["fvTenant"]["attributes"]["status"] == "created"
# Read back via the canonical path.
get_resp = c.get("/api/mo/uni/tn-PR10AliasTest.json")
assert get_resp.status_code == 200
assert get_resp.json()["totalCount"] == "1"
assert (
get_resp.json()["imdata"][0]["fvTenant"]["attributes"]["name"]
== "PR10AliasTest"
)
# Also readable via the alias itself.
get_alias_resp = c.get("/api/node/mo/uni/tn-PR10AliasTest.json")
assert get_alias_resp.status_code == 200
assert get_alias_resp.json()["totalCount"] == "1"
# Delete via the alias.
del_resp = c.delete("/api/node/mo/uni/tn-PR10AliasTest.json")
assert del_resp.status_code == 200
assert del_resp.json()["totalCount"] == "0"
# Confirm removal via the canonical path.
confirm_resp = c.get("/api/mo/uni/tn-PR10AliasTest.json")
assert confirm_resp.status_code == 200
assert confirm_resp.json()["totalCount"] == "0"
def test_node_mo_alias_requires_auth():
"""The alias must be gated by the same session check as /api/mo/*."""
c = _new_apic_client()
resp = c.get("/api/node/mo/uni/tn-Whatever.json")
assert resp.status_code == 403
assert resp.json()["imdata"][0]["error"]["attributes"]["code"] == "403"
def test_node_mo_alias_does_not_shadow_node_class():
"""/api/node/mo/* and /api/node/class/* are distinct literal prefixes —
the fabric-wide node/class form must still resolve correctly.
"""
c = _logged_in_apic_client()
resp = c.get("/api/node/class/fabricNode.json")
assert resp.status_code == 200
assert int(resp.json()["totalCount"]) > 0
# ---------------------------------------------------------------------------
# FIX A — unmatched /api/* fallback
# ---------------------------------------------------------------------------
def test_unmatched_api_path_returns_apic_shaped_400():
c = _logged_in_apic_client()
resp = c.get("/api/bogus/nonexistent/path")
assert resp.status_code == 400
body = resp.json()
assert "detail" not in body
assert body["imdata"][0]["error"]["attributes"]["code"] == "400"
assert "Invalid request path" in body["imdata"][0]["error"]["attributes"]["text"]
def test_unmatched_non_api_path_unaffected():
"""Paths outside /api/* (e.g. control-plane) keep the default 404 shape —
the fallback is scoped to the APIC REST contract only.
"""
c = _logged_in_apic_client()
resp = c.get("/_sim/does-not-exist")
assert resp.status_code == 404
assert "detail" in resp.json()
# ---------------------------------------------------------------------------
# FIX B — NDO site names == fabric names
# ---------------------------------------------------------------------------
def test_ndo_sites_use_fabric_names(ndo_client, ndo_token, topo):
resp = ndo_client.get(
"/mso/api/v1/sites",
headers={"Authorization": f"Bearer {ndo_token}"},
)
assert resp.status_code == 200
sites = resp.json()["sites"]
reported_names = {s["name"] for s in sites}
expected_fabric_names = {(s.fabric_name or s.name) for s in topo.sites}
assert reported_names == expected_fabric_names, (
f"NDO site names {reported_names} must equal topology fabric names "
f"{expected_fabric_names}, per real-gear cisco.mso.mso_tenant "
f"acceptance (see docs/CONTRACT.md §7)."
)
# And explicitly NOT the short internal names (guards against a
# regression that silently reverts to site.name).
short_names = {s.name for s in topo.sites}
assert reported_names.isdisjoint(short_names - expected_fabric_names)
# ---------------------------------------------------------------------------
# FIX C — schema list-identity
# ---------------------------------------------------------------------------
def test_schema_list_identity_returns_all_seeded_schemas(ndo_client, ndo_token):
resp = ndo_client.get(
"/mso/api/v1/schemas/list-identity",
headers={"Authorization": f"Bearer {ndo_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert "schemas" in data
schemas = data["schemas"]
assert len(schemas) >= 3, "expected at least the 3 seeded schemas"
display_names = {s["displayName"] for s in schemas}
for expected in ("MS-TN1-schema", "SF-TN1-LAB1-schema", "SF-TN1-LAB2-schema"):
assert expected in display_names, f"{expected} missing from list-identity"
for s in schemas:
assert "id" in s
assert "displayName" in s
def test_schema_list_identity_not_shadowed_by_id_route(ndo_client, ndo_token):
"""Route-ordering regression guard: list-identity must be declared before
the parameterized /schemas/{schema_id} route, or FastAPI matches
"list-identity" as a schema_id and 404s with
{"detail": "Schema 'list-identity' not found"}.
"""
resp = ndo_client.get(
"/mso/api/v1/schemas/list-identity",
headers={"Authorization": f"Bearer {ndo_token}"},
)
assert resp.status_code == 200
assert "detail" not in resp.json()
assert "schemas" in resp.json()
# ---------------------------------------------------------------------------
# FIX C — /api/v1/tenants
# ---------------------------------------------------------------------------
def test_bare_api_v1_tenants_returns_tenant_list(ndo_client, ndo_token, topo):
resp = ndo_client.get(
"/api/v1/tenants",
headers={"Authorization": f"Bearer {ndo_token}"},
)
assert resp.status_code == 200
data = resp.json()
assert "tenants" in data
tenants = data["tenants"]
assert len(tenants) == len(topo.tenants)
tenant_names = {t["name"] for t in tenants}
for tt in topo.tenants:
assert tt.name in tenant_names
for t in tenants:
assert "id" in t
assert "siteAssociations" in t